fix(net): detect half-open control connection via independent keepalive #101

Merged
Claude-Run merged 1 commit from fix/VA-101-keepalive-half-open-detection into main 2026-07-03 14:36:01 +02:00
Member

The control-channel keepalive in idle_loop derived liveness entirely from read-silence: the tokio::time::timeout(idle_timeout, ...) was rebuilt inside the select! on every iteration, so any inbound frame or any outbound outbox_rx send re-armed the window and the ping only fired after a full idle_timeout of silence in both directions. On a half-open socket self.ws.send(...) succeeds locally without a reply, so a session with periodic outbound traffic re-armed the window forever and never ran the health check, leaving the agent believing a dead connection was healthy for hours while the server had torn it down.

Replace the read-silence probe with an independent tokio::time::interval arm that emits Message::Ping every keepalive_interval regardless of other traffic. A new Keepalive tracker records the instant of the last inbound frame; only a received frame (Pong or any other inbound message) resets it, never an outbound send. When no inbound frame arrives within keepalive_timeout, the loop returns NetError::Disconnected("keepalive timeout") and the existing reconnect/backoff loop takes over. idle_timeout is retained only as an inbound-read backstop and no longer gates the ping.

Add keepaliveIntervalSecs (default 30s) and keepaliveTimeoutSecs (default 90s) as ConnectionSettings tunables, thread them through ConnectConfig, and log them with the other connection tunables at startup. Unit tests cover the tracker, including the outbound-traffic-masking case: a session pushing periodic outbound frames on a half-open socket still expires within keepalive_timeout because outbound sends never reset the deadline.

#VA-101

The control-channel keepalive in `idle_loop` derived liveness entirely from read-silence: the `tokio::time::timeout(idle_timeout, ...)` was rebuilt inside the `select!` on every iteration, so any inbound frame or any outbound `outbox_rx` send re-armed the window and the ping only fired after a full `idle_timeout` of silence in both directions. On a half-open socket `self.ws.send(...)` succeeds locally without a reply, so a session with periodic outbound traffic re-armed the window forever and never ran the health check, leaving the agent believing a dead connection was healthy for hours while the server had torn it down. Replace the read-silence probe with an independent `tokio::time::interval` arm that emits `Message::Ping` every `keepalive_interval` regardless of other traffic. A new `Keepalive` tracker records the instant of the last inbound frame; only a received frame (Pong or any other inbound message) resets it, never an outbound send. When no inbound frame arrives within `keepalive_timeout`, the loop returns `NetError::Disconnected("keepalive timeout")` and the existing reconnect/backoff loop takes over. `idle_timeout` is retained only as an inbound-read backstop and no longer gates the ping. Add `keepaliveIntervalSecs` (default 30s) and `keepaliveTimeoutSecs` (default 90s) as `ConnectionSettings` tunables, thread them through `ConnectConfig`, and log them with the other connection tunables at startup. Unit tests cover the tracker, including the outbound-traffic-masking case: a session pushing periodic outbound frames on a half-open socket still expires within `keepalive_timeout` because outbound sends never reset the deadline. #VA-101
fix(net): detect half-open control connection via independent keepalive
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 6m20s
Create release / Create release from merged PR (pull_request) Has been skipped
8f388c429f
The control-channel keepalive in `idle_loop` derived liveness entirely from read-silence: the `tokio::time::timeout(idle_timeout, ...)` was rebuilt inside the `select!` on every iteration, so any inbound frame or any outbound `outbox_rx` send re-armed the window and the ping only fired after a full `idle_timeout` of silence in both directions. On a half-open socket `self.ws.send(...)` succeeds locally without a reply, so a session with periodic outbound traffic re-armed the window forever and never ran the health check, leaving the agent believing a dead connection was healthy for hours while the server had torn it down.

Replace the read-silence probe with an independent `tokio::time::interval` arm that emits `Message::Ping` every `keepalive_interval` regardless of other traffic. A new `Keepalive` tracker records the instant of the last inbound frame; only a received frame (Pong or any other inbound message) resets it, never an outbound send. When no inbound frame arrives within `keepalive_timeout`, the loop returns `NetError::Disconnected("keepalive timeout")` and the existing reconnect/backoff loop takes over. `idle_timeout` is retained only as an inbound-read backstop and no longer gates the ping.

Add `keepaliveIntervalSecs` (default 30s) and `keepaliveTimeoutSecs` (default 90s) as `ConnectionSettings` tunables, thread them through `ConnectConfig`, and log them with the other connection tunables at startup. Unit tests cover the tracker, including the outbound-traffic-masking case: a session pushing periodic outbound frames on a half-open socket still expires within `keepalive_timeout` because outbound sends never reset the deadline.

#VA-101
Claude-Run deleted branch fix/VA-101-keepalive-half-open-detection 2026-07-03 14:36:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-agent!101
No description provided.