fix: harden xDS stream handling against reconnects and faulty nodes - #14
Open
dergraf wants to merge 7 commits into
Open
fix: harden xDS stream handling against reconnects and faulty nodes#14dergraf wants to merge 7 commits into
dergraf wants to merge 7 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Streamclassified discovery requests by doing arithmetic on theversion_infothe node reports. That value is opaque to the node and survivesreconnects: after a control plane redeploy a node replays a version the new
instance never issued, while its per-stream counter restarts at 0. The
casehad no clause for that:
Envoy last applied
"7"; we pushv1; Envoy re-sends still reporting"7"(normal for RDS/EDS/SDS when the subscription set changes) →
state.version = 1,version = 7,waiting_ack = 1, and1 > 7is false →CaseClauseError.Since
Stream.event/4dispatches withGenServer.call, the crash exits thecaller — 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_infois 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
cluster — pushes ran serially inside the
ConfigCacheGenServer and wrote tothe gRPC stream on that critical path. Now concurrent, fault-isolated, and the
write happens off the caller's path.
load_events/4returned:okwhen config generation raised.the same object).
Snapshot's disabled-state clause was dead code and returned an invalidGenServer reply;
force_persist/0raised with snapshots disabled.ensure_registred/3andevent/4.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/4takes a map instead of a{version, error}tuple and neverraises.
:temporary, not:transient.load_events/4no longer blocks theConfigCacheGenServer during its syncwait, and returns
{:error, ...}on generation failure.Stream.sync_status/1—in_sync/1reportstruewhen nothing isconnected, which it cannot distinguish from "in sync".
load_events/4stillreturns
:okin that case for compatibility, but logs a warning.:stream_push_timeout(10s),:max_concurrent_streams(
:infinity).How to test
Every fix has a test that fails against the previous code. The reconnect bug was
reproduced first:
{:case_clause, 1}atstream.ex:111, surfacing as an{:exit, ...}out ofGenServer.call— i.e. it takes the handler with it.flaky when first written and were fixed to synchronise on the now-async push)
mix format --check-formattedandmix compile --warnings-as-errorscleanNot 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/4for its cluster returns{:error, :no_sync_state_reached}. Papering over that with a grace period wouldweaken the sync guarantee, so it is left as a documented limitation.
Note
chore(dev)removesexport MIX_ENV=devfrom the flake's shell hook — it isMix's default anyway and made
mix testrun in the dev environment wheretest-only deps are unavailable.