Recover stalled Remote Control relay generations in-process
Summary
Remote Control should recover stalled or failed relay WebSocket generations inside the existing app-server process, without restarting app-server and without disrupting local TUI clients.
Related bug report: #26786
Problem
When the Remote Control relay transport fails, the current user-visible recovery path is often to restart app-server. That recovers Remote Control, but it also disconnects every active local TUI/app-server client sharing the same app-server process.
The desired recovery boundary is narrower:
relay WebSocket disconnects, stalls, or a connection worker stops
-> mark Remote Control as non-connected promptly
-> cancel and drain only the current relay connection generation
-> reconnect the relay WebSocket
-> keep app-server PID, Unix socket, enrollment, installation_id, and local clients intact
This issue is a solution proposal for the relay transport/liveness layer. It is not intended to cover every possible symptom in #26786. If ordinary local app-server RPCs also stop responding when Remote Control is stuck, that likely points to a broader app-server dispatcher/local transport problem and should be diagnosed separately.
Proposed scope
Handle these cases in codex-app-server-transport Remote Control code:
- relay WebSocket closes or errors;
- relay connection remains open but stops producing pong frames;
- one connection worker exits while another worker is still draining;
- status should not remain
connectedafter the current connection generation is known dead.
Do not handle these in the first transport-scoped change:
- restarting app-server or daemon;
- repairing daemon ownership or "unmanaged app-server" states;
- deleting or replacing enrollment;
- rotating
installation_id; - adding a public reconnect RPC;
- changing app-server protocol/API;
- backend presence expiry while the relay WebSocket is otherwise healthy.
Proposed implementation
Keep the existing long-lived RemoteControlWebsocket state intact:
- enrollment/current enrollment;
- pairing persistence key;
- client tracker;
- outbound buffer;
- subscribe cursor;
- sequence/cursor state;
- desired-state and status publishers.
Treat only the following as per-connection-generation state:
WebSocketStream;- reader worker;
- writer worker;
- child cancellation token;
- ping/pong deadlines;
- connection drain timeout.
The minimal behavior change is:
- When either connection worker stops, immediately publish
RemoteControlConnectionStatus::Connecting. - Cancel only the current connection generation.
- Drain or abort the sibling worker using the existing connection shutdown timeout.
- Return to the existing reconnect loop to establish the next WebSocket generation.
This prevents stale local status from remaining connected after the relay generation has already failed, and it avoids restarting app-server or rebuilding long-lived Remote Control state.
Local regression tests
I have a local patch with deterministic fake-relay tests against current upstream/main:
2c5bc5e284402d2e618c15e396d2e81bcc1ec257
The main regression test exercises:
- Start Remote Control against a local fake relay.
- Complete enrollment and establish the first relay WebSocket.
- Observe
connected. - Keep the TCP/WebSocket connection open but stop producing pong frames.
- Advance past the pong timeout.
- Assert status transitions to
connecting. - Assert a second relay WebSocket handshake is established.
- Assert a remote
initializemessage over the second generation still reaches the transport.
A smaller unit test covers the lifecycle edge case where one connection worker stops while the sibling worker is still stuck during drain. It asserts status becomes connecting before drain completes.
Validation:
just test -p codex-app-server-transport \
remote_control_transport_reconnects_after_disconnect \
remote_control_transport_reconnects_after_pong_timeout \
run_connection_marks_status_connecting_when_worker_stops_before_drain
3 tests run: 3 passed
Acceptance criteria
- A stalled relay connection establishes a new WebSocket generation in the same app-server process.
- app-server PID is unchanged.
- local Unix socket transport is not deleted or recreated.
- existing local TUI/app-server clients are not disconnected by relay recovery.
- status does not remain
connectedafter a relay generation has failed. - user-initiated Remote Control disable still stops retrying.
- app-server shutdown still interrupts reconnect/backoff promptly.
- enrollment is not recreated.
installation_idis not changed.- no public API surface is added.
- existing normal WebSocket close reconnect behavior continues to pass.
Open questions
- Is
connectingthe preferred transient status when a previously connected relay generation stops, or should this publisherroredfirst and thenconnecting? - Would maintainers prefer the first PR to only fix status invalidation and pong-timeout recovery, leaving structured reader/writer exit reasons and generation-id logging to a follow-up?
- For #26786 specifically, should reporters collect paired results for
remoteControl/status/readand a non-Remote-Control RPC such asconfig/readat failure time, to distinguish relay-layer failure from app-server dispatcher failure?
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗