Skip to content

fix: harden xDS stream handling against reconnects and faulty nodes - #14

Open
dergraf wants to merge 7 commits into
mainfrom
fix/xds-reconnect-robustness
Open

fix: harden xDS stream handling against reconnects and faulty nodes#14
dergraf wants to merge 7 commits into
mainfrom
fix/xds-reconnect-robustness

Conversation

@dergraf

@dergraf dergraf commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Fixes a bug where a reconnecting Envoy could crash its own ADS stream, plus nine
other defects found in the same audit. Details in CHANGELOG.md.

The reconnect bug

ExControlPlane.Stream classified discovery requests by doing arithmetic on the
version_info the node reports. That value is opaque to the node and survives
reconnects
: after a control plane redeploy a node replays a version the new
instance never issued, while its per-stream counter restarts at 0. The case
had no clause for that:

case state.version do
  ^version when is_nil(error) -> ...                                  # ACK
  nil when is_nil(error) -> ...                                       # "new node"
  _ when is_nil(error) and waiting_ack != nil and waiting_ack > version -> ...
  _ when not is_nil(error) -> ...                                     # NACK
end

Envoy last applied "7"; we push v1; Envoy re-sends still reporting "7"
(normal for RDS/EDS/SDS when the subscription set changes) → state.version = 1,
version = 7, waiting_ack = 1, and 1 > 7 is false → CaseClauseError.
Since Stream.event/4 dispatches with GenServer.call, the crash exits the
caller — the gRPC stream handler — so the ADS session drops and Envoy
reconnects into the same trap.

Requests are now classified by response_nonce, as the xDS protocol defines
(the field was decoded and thrown away before): empty nonce = initial request,
new or reconnected; our latest nonce = ACK/NACK; anything else = superseded.
version_info is never parsed.

Why it matters

Envoy keeps serving its last-good config when xDS drops, so this is silent until
config actually needs to change — at which point the fleet is frozen, including
SDS certificate rotation. It is triggered by exactly the events that should
be safe: rolling redeploy, scale-down, pod restart.

Other fixes

  • One slow or dead node could crash or block config distribution for every
    cluster — pushes ran serially inside the ConfigCache GenServer and wrote to
    the gRPC stream on that critical path. Now concurrent, fault-isolated, and the
    write happens off the caller's path.
  • load_events/4 returned :ok when config generation raised.
  • One malformed resource from the adapter killed the stream.
  • One bad snapshot could prevent start-up permanently (crash → restart → re-read
    the same object).
  • Snapshot's disabled-state clause was dead code and returned an invalid
    GenServer reply; force_persist/0 raised with snapshots disabled.
  • Non-exhaustive error handling in ensure_registred/3 and event/4.
  • The node identifier is now remembered per stream — xDS only requires it on the
    first request. Envoy sends it every time, so this is hardening for other
    spec-conformant xDS clients rather than a live bug.

Behaviour changes

  • Stream.event/4 takes a map instead of a {version, error} tuple and never
    raises.
  • Stream children are :temporary, not :transient.
  • load_events/4 no longer blocks the ConfigCache GenServer during its sync
    wait, and returns {:error, ...} on generation failure.
  • New Stream.sync_status/1in_sync/1 reports true when nothing is
    connected, which it cannot distinguish from "in sync". load_events/4 still
    returns :ok in that case for compatibility, but logs a warning.
  • New config: :stream_push_timeout (10s), :max_concurrent_streams
    (:infinity).

How to test

mix test --exclude integration     # 99 tests
mix test --only integration        # needs an envoy binary on PATH

Every fix has a test that fails against the previous code. The reconnect bug was
reproduced first: {:case_clause, 1} at stream.ex:111, surfacing as an
{:exit, ...} out of GenServer.call — i.e. it takes the handler with it.

  • Unit: 99 tests, 0 failures (verified across 5 seeds — three of these tests were
    flaky when first written and were fixed to synchronise on the now-async push)
  • Integration vs. real Envoy 1.34.2: 0 failures
  • mix format --check-formatted and mix compile --warnings-as-errors clean

Not addressed

Detecting that a node is gone is a transport concern and takes ~50s (measured
against Envoy 1.34.2). A node that dies mid-push leaves an out-of-sync stream for
that long, during which load_events/4 for its cluster returns
{:error, :no_sync_state_reached}. Papering over that with a grace period would
weaken the sync guarantee, so it is left as a documented limitation.

Note

chore(dev) removes export MIX_ENV=dev from the flake's shell hook — it is
Mix's default anyway and made mix test run in the dev environment where
test-only deps are unavailable.

A reconnecting Envoy could crash its own ADS stream, repeatedly. The stream
classified discovery requests by doing arithmetic on the version_info the node
reports, but that value is opaque to the node and survives reconnects: after a
control plane redeploy a node replays a version the new instance never issued,
while its per-stream counter restarts at 0. The case had no clause for that and
raised CaseClauseError, which exited the caller - the GRPC stream handler -
dropping the session and starting the cycle again.

Requests are now classified by response_nonce, as the xDS protocol defines:
empty nonce means initial request (new *or* reconnected), our latest nonce means
ACK/NACK, anything else is a superseded response. version_info is never parsed.

The same audit turned up other ways a single misbehaving node, adapter, or
snapshot could take down more than itself:

- push_resource_changes/3 ran serially inside the ConfigCache GenServer with a
  5s call timeout, and wrote to the GRPC stream on that critical path. A dead
  pid or a node stuck on HTTP/2 flow control crashed or blocked config
  distribution for every cluster. Pushes are now concurrent and fault-isolated,
  and a stream marks itself out of sync, replies, then writes in a continue.
- The DiscoveryResponse encode was an unguarded match, so one malformed resource
  killed the stream. Failures now leave version and hash unadvanced so the next
  notification retries.
- load_events/4 reported :ok when config generation raised - the error was
  caught, logged, and the result discarded.
- Snapshot validation was partial: a payload without version or checksum
  information raised inside handle_continue and the restart re-read the same
  object, so one bad snapshot could prevent start-up permanently.
- Snapshot init/1 stored :no_snapshot_config while the disabled-state clause
  matched :snapshots_disabled, leaving that clause dead - and it returned
  {:ok, reply, state}, which is not a valid GenServer reply.
- ensure_registred/3 and event/4 had non-exhaustive error handling.
- The node identifier is remembered per stream; xDS only requires it on the
  first request, and a request without it raised FunctionClauseError.

Stream children are :temporary rather than :transient: a restarted stream only
re-registers against a connection the node must re-establish anyway, while
spending the supervisor's restart budget.

Adds Stream.sync_status/1 to distinguish "in sync" from "no node connected" -
in_sync/1 reports true for both.
Several changes in this release are visible to callers (Stream.event/4 takes a
map, load_events/4 returns generation errors, snapshot state atom), so record
them. Also documents the config options and what each load_events/4 return value
means, including that :ok covers "no node connected".
Ships CHANGELOG.md in the hex package and the generated docs.
The shell hook exported MIX_ENV=dev, which is Mix's default anyway and made
`mix test` run in the dev environment, where test-only deps like finch are not
available. Removing it lets plain `mix test` work.

Also allowlists read-only mix commands for Claude Code and gitignores the local
settings file.
CI failed on one matrix cell (Elixir 1.18 / Envoy 1.37.0) in stream_test.exs:
the shared StreamSupervisor held 3 children where the test created 2. The log
shows why - mid-test, the integration suite's Envoy reconnected:

  [cluster: "cluster"] ScopedRouteConfiguration registered.   <- the test's mocks
  [cluster: "cluster"] ScopedRouteConfiguration registered.
  Handled by ...stream_aggregated_resources
  [cluster: "test-cluster"] Cluster registered.  Initial request by test-node-1
  [cluster: "test-cluster"] Listener registered. Initial request by test-node-1

stop_envoy/1 sent SIGTERM, slept 500ms, sent SIGKILL and never checked whether
the process died. Envoy runs with --drain-time-s 1 --parent-shutdown-time-s 2,
so a graceful shutdown can outlast that window; a survivor then retries its ADS
connection forever and registers streams in whichever test is running next. The
kill is now confirmed by polling until the OS process is gone.

stream_test.exs also asserted on global Registry.count/count_children, so any
connection to the control plane broke it. It now asserts on the pids and
registry keys it owns.

Also adds the missing wait/2 timeout clause - on timeout it raised
FunctionClauseError instead of failing the assertion.
The private await_os_exit/2 helpers were inserted between the two stop_envoy/1
clauses, which mix compile --warnings-as-errors rejects. Broke every test matrix
cell at the compile step.
Same failure as stream_test.exs, in the file added by this branch: asserting on
DynamicSupervisor.count_children sees whatever else is connected to the control
plane. A stray Envoy's two streams made it 2 instead of 0.

The registry lookup on the stream's own key already proves the child was not
restarted, so the supervisor check only needs to confirm that pid is gone.

An audit for count_children/Registry.count over test/ shows this was the last
global-state assertion; everything else is scoped to a cluster id or owned pid.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant