Skip to content

xds: resolve onError / channel teardown lock ordering conflict - #13089

Closed
bcleenders wants to merge 1 commit into
grpc:masterfrom
bcleenders:fix/xds-async-error-delivery
Closed

bcleenders wants to merge 1 commit into
grpc:masterfrom
bcleenders:fix/xds-async-error-delivery

Conversation

@bcleenders

Copy link
Copy Markdown
Contributor

onError is the only notification path in DynamicSslContextProvider that
bypasses performCallback and calls callback.onException() directly on
the caller's thread. Other update paths (updateSslContext / addCallback)
already dispatch through performCallback.

Staying on the same thread holds on to DistributorWatcher lock too long, and
creates a lock-ordering cycle when a file_watcher cert provider fails to
reload certificates:

Thread A (cert reload):
  DistributorWatcher.onError()      — holds DistributorWatcher lock
    → DynamicSslContextProvider.onError()
      → callback.onException()      — synchronous, still holds lock, PR changes this
        → releaseSslContextProvider()
          → ReferenceCountingMap.releaseInternal()  — wants map lock

Thread B (channel teardown):
  ReferenceCountingMap.releaseInternal()  — holds map lock
    → value.close()
      → Handle.close()
        → DistributorWatcher.removeWatcher()  — wants DistributorWatcher lock

The fix routes onError through performCallback, matching the update
path. The callback runs on its own executor after the caller releases the
DistributorWatcher lock, eliminating the lock-ordering edge.

`onError` is the only notification path in `DynamicSslContextProvider` that
bypasses `performCallback` and calls `callback.onException()` directly on
the caller's thread. Other update paths (`updateSslContext` / `addCallback`)
already dispatch through `performCallback`.

Staying on the same thread holds on to `DistributorWatcher` lock too long, and
creates a lock-ordering cycle when a `file_watcher` cert provider fails to
reload certificates:

```
Thread A (cert reload):
  DistributorWatcher.onError()      — holds DistributorWatcher lock
    → DynamicSslContextProvider.onError()
      → callback.onException()      — synchronous, still holds lock, we break this
        → releaseSslContextProvider()
          → ReferenceCountingMap.releaseInternal()  — wants map lock

Thread B (channel teardown):
  ReferenceCountingMap.releaseInternal()  — holds map lock
    → value.close()
      → Handle.close()
        → DistributorWatcher.removeWatcher()  — wants DistributorWatcher lock
```

The fix routes `onError` through `performCallback`, matching the update
path. The callback runs on its own executor after the caller releases the
DistributorWatcher lock, eliminating the lock-ordering edge.
@bcleenders
bcleenders marked this pull request as ready for review October 5, 2026 13:11
bcleenders added a commit to bcleenders/grpc-java that referenced this pull request Oct 5, 2026
Reviewer feedback: instead of holding the intrinsic lock during watcher
callbacks (and using `ImmutableSet.copyOf` to tolerate reentrant
modification), decouple state mutation from callback delivery using
`SynchronizationContext`.

Under the intrinsic lock, state is updated and callbacks are enqueued
via `executeLater`. After releasing the lock, `drain()` delivers the
callbacks. This guarantees:

- No lock held during callbacks (prevents deadlocks with downstream
  locks such as `ReferenceCountingMap`)
- Serialized, non-reentrant delivery (a callback that calls
  `addWatcher` enqueues work that runs after the current callback)

This also subsumes the `DynamicSslContextProvider.onError` deadlock
fix from grpc#13089: the `DistributorWatcher` lock is no longer held when
`onError` callbacks run, so the lock-ordering cycle between
`DistributorWatcher` and `ReferenceCountingMap` cannot occur.

Exceptions from watcher callbacks no longer propagate to the caller
(`SynchronizationContext` catches and logs them). The only caller that
relied on propagation is `FileWatcherCertificateProvider`, which skips
a `lastModifiedTime` update when `updateTrustedRoots` throws, causing
a retry on the next refresh cycle. This retry was ineffective: the same
cert data produces the same deterministic failure (e.g., a static
`CertificateValidationContext` rejecting the cert), so retrying just
repeats the same error until the file changes on disk. The error is
still delivered to the affected watcher via
`DynamicSslContextProvider.onError()`.

Co-Authored-By: Claude Oratorio 5.0 <noreply@anthropic.com>
bcleenders added a commit to bcleenders/grpc-java that referenced this pull request Oct 5, 2026
Reviewer feedback: instead of holding the intrinsic lock during watcher
callbacks (and using `ImmutableSet.copyOf` to tolerate reentrant
modification), decouple state mutation from callback delivery using
`SynchronizationContext`.

Under the intrinsic lock, state is updated and callbacks are enqueued
via `executeLater`. After releasing the lock, `drain()` delivers the
callbacks. This guarantees:

- No lock held during callbacks (prevents deadlocks with downstream
  locks such as `ReferenceCountingMap`)
- Serialized, non-reentrant delivery (a callback that calls
  `addWatcher` enqueues work that runs after the current callback)

This also subsumes the `DynamicSslContextProvider.onError` deadlock
fix from grpc#13089: the `DistributorWatcher` lock is no longer held when
`onError` callbacks run, so the lock-ordering cycle between
`DistributorWatcher` and `ReferenceCountingMap` cannot occur.

Exceptions from watcher callbacks no longer propagate to the caller
(`SynchronizationContext` catches and logs them). The only caller that
relied on propagation is `FileWatcherCertificateProvider`, which skips
a `lastModifiedTime` update when `updateTrustedRoots` throws, causing
a retry on the next refresh cycle. This retry was ineffective: the same
cert data produces the same deterministic failure (e.g., a static
`CertificateValidationContext` rejecting the cert), so retrying just
repeats the same error until the file changes on disk. The error is
still delivered to the affected watcher via
`DynamicSslContextProvider.onError()`.
bcleenders added a commit to bcleenders/grpc-java that referenced this pull request Oct 5, 2026
Reviewer feedback: instead of holding the intrinsic lock during watcher
callbacks (and using `ImmutableSet.copyOf` to tolerate reentrant
modification), decouple state mutation from callback delivery using
`SynchronizationContext`.

Under the intrinsic lock, state is updated and callbacks are enqueued
via `executeLater`. After releasing the lock, `drain()` delivers the
callbacks. This guarantees:

- No lock held during callbacks (prevents deadlocks with downstream
  locks such as `ReferenceCountingMap`)
- Serialized, non-reentrant delivery (a callback that calls
  `addWatcher` enqueues work that runs after the current callback)

This also subsumes the `DynamicSslContextProvider.onError` deadlock
fix from grpc#13089: the `DistributorWatcher` lock is no longer held when
`onError` callbacks run, so the lock-ordering cycle between
`DistributorWatcher` and `ReferenceCountingMap` cannot occur.

Exceptions from watcher callbacks no longer propagate to the caller
(`SynchronizationContext` catches and logs them). The only caller that
relied on propagation is `FileWatcherCertificateProvider`, which skips
a `lastModifiedTime` update when `updateTrustedRoots` throws, causing
a retry on the next refresh cycle. This retry was ineffective: the same
cert data produces the same deterministic failure (e.g., a static
`CertificateValidationContext` rejecting the cert), so retrying just
repeats the same error until the file changes on disk. The error is
still delivered to the affected watcher via
`DynamicSslContextProvider.onError()`.
@bcleenders

Copy link
Copy Markdown
Contributor Author

Addressed by fc04aa5

@bcleenders bcleenders closed this Oct 5, 2026
@bcleenders
bcleenders deleted the fix/xds-async-error-delivery branch October 5, 2026 18:13
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