Skip to content

xDS: fix DistributorWatcher thread safety in CertificateProvider - #13087

Draft
bcleenders wants to merge 2 commits into
grpc:masterfrom
bcleenders:fix/xds-certificate-watcher-race
Draft

bcleenders wants to merge 2 commits into
grpc:masterfrom
bcleenders:fix/xds-certificate-watcher-race

Conversation

@bcleenders

Copy link
Copy Markdown

A gRPC client using xDS-driven mTLS does two things concurrently:

  1. Certificate provider: watches cert files on disk, periodically reloads them, and notifies subscribers when certs change.
  2. Channel subscriptions: each gRPC channel that needs mTLS registers a watcher on the cert provider to get the TLS credentials.

The DistributorWatcher sits in the middle and fans out: one cert provider pushes updates to it, and it distributes to N channel watchers.

I think there are two conditions in this that can lead to a ConcurrentModificationException:

  1. updateSpiffeTrustMap isn't synchronized (unlike updateCertificate and updateTrustedRoots), so it can race against addWatcher/removeWatcher from another thread.
  2. if callbacks mutate their own subscription. A callchain (line numbers per 1.83.0 / 1084e97) for that is:
  DistributorWatcher.onError()                              CertificateProvider.java:126
    → for (watcher : downstreamWatchers)                    CertificateProvider.java:127
      → watcher.onError(errorStatus)                        CertificateProvider.java:128
        → DynamicSslContextProvider.onError()               DynamicSslContextProvider.java:143
          → callback.onException(error)                     DynamicSslContextProvider.java:145
            → SslContextProviderSupplier.onException()      SslContextProviderSupplier.java:79
              → releaseSslContextProvider(toRelease)        SslContextProviderSupplier.java:81
                → TlsContextManagerImpl.releaseClient...()  TlsContextManagerImpl.java:78
                  → ReferenceCountingMap.releaseInternal()  ReferenceCountingMap.java:89
                    → if refcount == 0: value.close()       ReferenceCountingMap.java:95
                      → CertProviderSslContextProvider.close()  CertProviderSslContextProvider.java:216
                        → certHandle.close()                CertProviderSslContextProvider.java:218
                          → Handle.close()                  CertificateProviderStore.java:62
                            → distWatcher.removeWatcher()   CertificateProviderStore.java:64  <-- REENTRANT

We observed ConcurrentModificationException in updateSpiffeTrustMap() in production, consistent with the unsynchronized iteration described above. We also observed startup RPC deadlines during the rollout, but have not established the causal connection between these symptoms.

These all occurred in a service that creates many (~60) channels on startup, while getting the CDS config over xDS. The service uses grpc-java / grpc-xds 1.83.0. When we switched it to use mTLS with SPIFFE certificates, we got the following errors:

DEADLINE_EXCEEDED: CallOptions deadline exceeded after 4.998s.
Name resolution delay 0.000000000 seconds.
[closed=[], open=[[connecting_and_lb_delay=4999ms, was_still_waiting]]]
  io.grpc.StatusException: UNKNOWN
    at io.grpc.xds.internal.security.DynamicSslContextProvider.onError(DynamicSslContextProvider.java:145)
    at io.grpc.xds.internal.security.certprovider.CertificateProvider$DistributorWatcher.onError(CertificateProvider.java:128)
    at io.grpc.xds.internal.security.certprovider.FileWatcherCertificateProvider.checkAndReloadCertificates(FileWatcherCertificateProvider.java:149)
  Caused by: java.util.ConcurrentModificationException
    at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1606)
    at java.base/java.util.HashMap$KeyIterator.next(HashMap.java:1629)
    at io.grpc.xds.internal.security.certprovider.CertificateProvider$DistributorWatcher.updateSpiffeTrustMap(CertificateProvider.java:120)
    at io.grpc.xds.internal.security.certprovider.FileWatcherCertificateProvider.checkAndReloadCertificates(FileWatcherCertificateProvider.java:144)
java.util.ConcurrentModificationException
  at java.util.HashMap$HashIterator.nextNode(HashMap.java:1606)
  at java.util.HashMap$KeyIterator.next(HashMap.java:1629)
  at CertificateProvider$DistributorWatcher.onError(CertificateProvider.java:127)
  at FileWatcherCertificateProvider.checkAndReloadCertificates(FileWatcherCertificateProvider.java:149)

`DistributorWatcher.updateSpiffeTrustMap()` is not `synchronized`, unlike
`updateCertificate()` and `updateTrustedRoots()` in the same class. This
causes `ConcurrentModificationException` when a watcher is added or
removed while `updateSpiffeTrustMap` iterates `downstreamWatchers`.

Additionally, all update methods iterate `downstreamWatchers` directly.
If a callback reentrantly calls `addWatcher`/`removeWatcher`, the
`HashSet` is modified during iteration, producing:

```
java.util.ConcurrentModificationException
  at java.util.HashMap$HashIterator.nextNode(HashMap.java:1606)
  at java.util.HashMap$KeyIterator.next(HashMap.java:1629)
  at DistributorWatcher.updateSpiffeTrustMap(CertificateProvider.java:120)
```

Observed in production when a service creates many mTLS-enabled gRPC
channels concurrently at startup (~60 channels across 7 threads). All
channels share the same `file_watcher` cert provider instance. The burst
of concurrent `addWatcher()` calls races with the provider's initial
`updateSpiffeTrustMap()` call, triggering the CME. The exception
propagates to `DynamicSslContextProvider.onError()`, preventing the TLS
context from being created. Affected subchannels remain in CONNECTING
until the deadline expires:

```
DEADLINE_EXCEEDED: CallOptions deadline exceeded after 4.998s.
Name resolution delay 0.000000000 seconds.
[closed=[], open=[[connecting_and_lb_delay=4999ms, was_still_waiting]]]
```

These tests demonstrate the race. They are expected to fail until the
fix is applied.
Add `synchronized` to `updateSpiffeTrustMap()`, matching `updateCertificate()` and `updateTrustedRoots()`.

Iterate a snapshot (`ImmutableSet.copyOf`) of `downstreamWatchers` in all notification methods, instead of iterating the live `HashSet`.
This prevents `ConcurrentModificationException` when a callback calls `addWatcher()`/`removeWatcher()`.

Mark other functions (`getLastIdentityCert()`, `close()` & `clearValues()`) which read/write state without holding the monitor as synchronized.
@linux-foundation-easycla

linux-foundation-easycla Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

This branch has not been deployed

No deployments
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