Skip to content

Commit a2210c6

Browse files
committed
fix(gax): address PR 13995 AI review findings and Javadoc doclint errors
Addresses AI code review findings from https://paste.googleplex.com/6563525517508608: - GrpcCallContext: Prevent transportChannel stale inheritance in merge() and withChannel() - RefreshingHttpJsonChannel: Set shutdownRequested and shutdownInitiated in shutdownNow() so newCall() throws IllegalStateException - AttemptCallable / StreamingCallables: Pass getCause() when rethrowing retryable UnauthenticatedException to prevent double-wrapping - CertificateBasedAccess: Enforce fail-closed security boundary when certificate config is malformed or missing required keys, and fix JSON unescaping order - ChannelPool: Update ReleasingClientCall Javadoc contract - Unit tests: Add cache invalidation test helpers to eliminate Thread.sleep() delays and add comprehensive tests for all addressed edge cases
1 parent 49772f0 commit a2210c6

16 files changed

Lines changed: 206 additions & 26 deletions

File tree

sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ChannelPool.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,11 @@ private String getOrUpdateDiskFingerprint(String certPath) {
485485
}
486486
}
487487

488+
@VisibleForTesting
489+
void invalidateDiskFingerprintCache() {
490+
this.lastDiskCheck = null;
491+
}
492+
488493
boolean shouldRefresh() {
489494
if (workloadCertPath == null) {
490495
return false;
@@ -513,6 +518,7 @@ void refresh() {
513518
// replaces the list)
514519
synchronized (entryWriteLock) {
515520
if (workloadCertPath == null) {
521+
refreshAll();
516522
return;
517523
}
518524
String currentDiskFingerprint = getOrUpdateDiskFingerprint(workloadCertPath);
@@ -721,9 +727,9 @@ public <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(
721727
/**
722728
* ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion.
723729
*
724-
* <p>Contract: Exactly one call to {@link #start(Listener, Metadata)} or explicit release via
725-
* {@link #cancel(String, Throwable)} is required to balance reference counts. Early cancellation
726-
* before {@code start()} safely decrements the reference count via atomic compare-and-set.
730+
* <p>Contract: Exactly one call to {@link #start(Listener, Metadata)} is required to balance
731+
* reference counts. Early cancellation before {@code start()} is recorded and safely decrements
732+
* the reference count when {@code start()} is subsequently invoked.
727733
*/
728734
static class ReleasingClientCall<ReqT, RespT> extends SimpleForwardingClientCall<ReqT, RespT> {
729735
private @Nullable CancellationException cancellationException;

sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,8 @@ public ApiCallContext merge(ApiCallContext inputCallContext) {
563563
}
564564

565565
TransportChannel newTransportChannel = grpcCallContext.transportChannel;
566-
if (newTransportChannel == null) {
566+
if (newTransportChannel == null
567+
&& (grpcCallContext.channel == null || grpcCallContext.channel.equals(channel))) {
567568
newTransportChannel = transportChannel;
568569
}
569570

@@ -662,7 +663,7 @@ public GrpcCallContext withChannel(@Nullable Channel newChannel) {
662663
retryableCodes,
663664
endpointContext,
664665
isDirectPath,
665-
transportChannel);
666+
(newChannel == null || newChannel.equals(channel)) ? transportChannel : null);
666667
}
667668

668669
/** Returns a new instance with the call options set to the given call options. */

sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ void channelReactiveMTlsRefreshShouldConditionallySwapChannels()
473473
.newCall(Mockito.<MethodDescriptor<String, Integer>>any(), Mockito.any(CallOptions.class));
474474

475475
// The ChannelPool caches fingerprints for 1000ms, wait for it to expire
476-
Thread.sleep(1100);
476+
pool.invalidateDiskFingerprintCache();
477477

478478
java.nio.file.Path rootCert =
479479
java.nio.file.Paths.get("src", "test", "resources", "root_cert.pem");
@@ -529,6 +529,37 @@ void channelRefreshShouldSwapChannels() throws IOException {
529529
.newCall(Mockito.<MethodDescriptor<String, Integer>>any(), Mockito.any(CallOptions.class));
530530
}
531531

532+
@Test
533+
void testRefreshWithNullWorkloadCertPathSwapsChannel() throws IOException {
534+
ScheduledExecutorService executor =
535+
Mockito.mock(ScheduledExecutorService.class, Mockito.withSettings().withoutAnnotations());
536+
FixedExecutorProvider provider = FixedExecutorProvider.create(executor);
537+
ManagedChannel underlyingChannel1 = Mockito.mock(ManagedChannel.class);
538+
ManagedChannel underlyingChannel2 = Mockito.mock(ManagedChannel.class);
539+
FakeChannelFactory channelFactory =
540+
new FakeChannelFactory(ImmutableList.of(underlyingChannel1, underlyingChannel2));
541+
pool =
542+
new ChannelPool(
543+
ChannelPoolSettings.staticallySized(1).toBuilder()
544+
.setPreemptiveRefreshEnabled(true)
545+
.build(),
546+
channelFactory,
547+
provider,
548+
null);
549+
Mockito.reset(underlyingChannel1);
550+
551+
pool.newCall(FakeMethodDescriptor.<String, Integer>create(), CallOptions.DEFAULT);
552+
Mockito.verify(underlyingChannel1, Mockito.only())
553+
.newCall(Mockito.<MethodDescriptor<String, Integer>>any(), Mockito.any(CallOptions.class));
554+
555+
// Calling refresh() when workloadCertPath is null should fall back to refreshAll()
556+
pool.refresh();
557+
558+
pool.newCall(FakeMethodDescriptor.<String, Integer>create(), CallOptions.DEFAULT);
559+
Mockito.verify(underlyingChannel2, Mockito.only())
560+
.newCall(Mockito.<MethodDescriptor<String, Integer>>any(), Mockito.any(CallOptions.class));
561+
}
562+
532563
@Test
533564
void channelCountShouldNotChangeWhenOutstandingRpcsAreWithinLimits() throws Exception {
534565
ScheduledExecutorService executor =

sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,4 +515,33 @@ public void testEqualsAndHashCode() {
515515

516516
org.junit.jupiter.api.Assertions.assertNotEquals(context1, context3);
517517
}
518+
519+
@Test
520+
public void testMergeWithCustomChannelClearsTransportChannel() {
521+
ManagedChannel defaultChannel = org.mockito.Mockito.mock(ManagedChannel.class);
522+
ManagedChannel customChannel = org.mockito.Mockito.mock(ManagedChannel.class);
523+
GrpcTransportChannel transportChannel = GrpcTransportChannel.create(defaultChannel);
524+
525+
GrpcCallContext baseContext =
526+
GrpcCallContext.createDefault().withTransportChannel(transportChannel);
527+
GrpcCallContext overrideContext = GrpcCallContext.of(customChannel, CallOptions.DEFAULT);
528+
529+
GrpcCallContext mergedContext = (GrpcCallContext) baseContext.merge(overrideContext);
530+
assertEquals(customChannel, mergedContext.getChannel());
531+
assertNull(mergedContext.getTransportChannel());
532+
}
533+
534+
@Test
535+
public void testWithChannelWithCustomChannelClearsTransportChannel() {
536+
ManagedChannel defaultChannel = org.mockito.Mockito.mock(ManagedChannel.class);
537+
ManagedChannel customChannel = org.mockito.Mockito.mock(ManagedChannel.class);
538+
GrpcTransportChannel transportChannel = GrpcTransportChannel.create(defaultChannel);
539+
540+
GrpcCallContext baseContext =
541+
GrpcCallContext.createDefault().withTransportChannel(transportChannel);
542+
GrpcCallContext updatedContext = baseContext.withChannel(customChannel);
543+
544+
assertEquals(customChannel, updatedContext.getChannel());
545+
assertNull(updatedContext.getTransportChannel());
546+
}
518547
}

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,10 @@ public TransportChannelProvider withCredentials(Credentials credentials) {
199199
if (certificateBasedAccess.useMtlsClientCertificate()) {
200200
KeyStore mtlsKeyStore = mtlsProvider.getKeyStore();
201201
if (mtlsKeyStore != null) {
202-
return new NetHttpTransport.Builder().trustCertificates(null, mtlsKeyStore, "").build();
202+
NetHttpTransport.Builder builder = new NetHttpTransport.Builder();
203+
builder.trustCertificates(null, mtlsKeyStore, "");
204+
HttpJsonConscryptUtils.configureConscryptSecurityProvider(builder);
205+
return builder.build();
203206
}
204207
}
205208
return null;

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ private ManagedHttpJsonChannel(
7878
this.executor = executor;
7979
this.usingDefaultExecutor = usingDefaultExecutor;
8080
this.endpoint = endpoint;
81-
this.httpTransport = httpTransport == null ? new NetHttpTransport() : httpTransport;
81+
this.httpTransport =
82+
httpTransport == null
83+
? HttpJsonConscryptUtils.configureConscryptSecurityProvider(
84+
new NetHttpTransport.Builder())
85+
.build()
86+
: httpTransport;
8287
this.usingDefaultTransport = usingDefaultTransport || httpTransport == null;
8388
this.deadlineScheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
8489
}

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,18 @@ public void shutdownNow() {
242242
synchronized (refreshLock) {
243243
isShuttingDown = true;
244244
for (ChannelEntry entry : allEntries) {
245+
entry.shutdownRequested.set(true);
246+
entry.shutdownInitiated.set(true);
245247
entry.channel.shutdownNow();
246248
}
247249
}
248250
}
249251

252+
@VisibleForTesting
253+
void invalidateDiskFingerprintCache() {
254+
this.lastDiskCheck = null;
255+
}
256+
250257
@Override
251258
public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException {
252259
long endNanos = System.nanoTime() + unit.toNanos(duration);

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,15 @@ void testShouldRefreshNullCertPath() {
177177
void testShouldRefreshFalseWhenUnchanged() throws InterruptedException {
178178
RefreshingHttpJsonChannel channel = createTestChannel();
179179

180-
Thread.sleep(1001); // Invalidate 1-second cache
180+
channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
181181
assertFalse(channel.shouldRefresh());
182182
}
183183

184184
@Test
185185
void testShouldRefreshTrueWhenChanged() throws InterruptedException {
186186
RefreshingHttpJsonChannel channel = createTestChannel();
187187

188-
Thread.sleep(1001); // Invalidate 1-second cache
188+
channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
189189

190190
// Simulate disk fingerprint changing
191191
testFingerprint = "fingerprint2";
@@ -199,7 +199,7 @@ void testRefreshSwapsChannel() throws InterruptedException {
199199
FakeManagedHttpJsonChannel firstChannel = lastCreatedChannel;
200200
assertEquals(1, channelFactoryCount.get());
201201

202-
Thread.sleep(1001); // Invalidate 1-second cache
202+
channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
203203

204204
// Change fingerprint
205205
testFingerprint = "fingerprint2";
@@ -227,7 +227,7 @@ void testRefreshKeepsInFlightChannelsAlive() throws InterruptedException {
227227

228228
HttpJsonClientCall<Object, Object> activeCall = channel.newCall(null, null);
229229

230-
Thread.sleep(1001); // Invalidate 1-second cache
230+
channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
231231

232232
// Change fingerprint & refresh
233233
testFingerprint = "fingerprint2";
@@ -262,7 +262,7 @@ void testRefreshDoesNotSpawnChannelWhenShutdown() throws InterruptedException {
262262
channel.shutdown();
263263
firstChannel.shutdown();
264264

265-
Thread.sleep(1001); // Invalidate 1-second cache
265+
channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
266266

267267
// Change fingerprint
268268
testFingerprint = "fingerprint2";
@@ -280,7 +280,7 @@ void testRefreshFactoryExceptionDoesNotWedgeFingerprint() throws InterruptedExce
280280
assertEquals(1, channelFactoryCount.get());
281281

282282
shouldThrowOnFactory = true;
283-
Thread.sleep(1001); // Invalidate 1-second cache
283+
channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
284284
testFingerprint = "fingerprint2";
285285

286286
assertThrows(RuntimeException.class, channel::refresh);
@@ -324,4 +324,48 @@ void testChannelDelegationMethods() {
324324
assertEquals(firstChannel.getHttpTransport(), channel.getHttpTransport());
325325
assertEquals(firstChannel.getExecutor(), channel.getExecutor());
326326
}
327+
328+
@Test
329+
void testNewCallAfterShutdownNowThrowsIllegalStateException() {
330+
RefreshingHttpJsonChannel channel = createTestChannel();
331+
channel.shutdownNow();
332+
333+
assertThrows(
334+
IllegalStateException.class,
335+
() -> channel.newCall(null, null),
336+
"Channel has been shut down");
337+
}
338+
339+
@Test
340+
void testConcurrentNewCallDuringRefresh() throws InterruptedException {
341+
RefreshingHttpJsonChannel channel = createTestChannel();
342+
int threadCount = 10;
343+
java.util.concurrent.ExecutorService executorService =
344+
java.util.concurrent.Executors.newFixedThreadPool(threadCount);
345+
java.util.concurrent.CountDownLatch latch =
346+
new java.util.concurrent.CountDownLatch(threadCount);
347+
java.util.concurrent.atomic.AtomicInteger successCount =
348+
new java.util.concurrent.atomic.AtomicInteger(0);
349+
350+
for (int i = 0; i < threadCount; i++) {
351+
executorService.submit(
352+
() -> {
353+
try {
354+
channel.newCall(null, null);
355+
successCount.incrementAndGet();
356+
} finally {
357+
latch.countDown();
358+
}
359+
});
360+
}
361+
362+
channel.invalidateDiskFingerprintCache();
363+
testFingerprint = "fingerprint2";
364+
channel.refresh();
365+
366+
latch.await(5, TimeUnit.SECONDS);
367+
executorService.shutdown();
368+
369+
assertEquals(threadCount, successCount.get());
370+
}
327371
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/retrying/RetrySettings.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public final org.threeten.bp.Duration getInitialRpcTimeout() {
189189
* connection has been terminated).
190190
*
191191
* <p>{@link #getTotalTimeout()} caps how long the logic should keep trying the RPC until it gives
192-
* up completely. If {@link #getTotalTimeout()} is set, initialRpcTimeout should be <=
192+
* up completely. If {@link #getTotalTimeout()} is set, initialRpcTimeout should be &lt;=
193193
* totalTimeout.
194194
*
195195
* <p>If there are no configurations, Retries have the default initial RPC timeout value of {@code
@@ -356,7 +356,7 @@ public final Builder setInitialRpcTimeout(org.threeten.bp.Duration initialTimeou
356356
* the connection has been terminated).
357357
*
358358
* <p>{@link #getTotalTimeout()} caps how long the logic should keep trying the RPC until it
359-
* gives up completely. If {@link #getTotalTimeout()} is set, initialRpcTimeout should be <=
359+
* gives up completely. If {@link #getTotalTimeout()} is set, initialRpcTimeout should be &lt;=
360360
* totalTimeout.
361361
*
362362
* <p>If there are no configurations, Retries have the default initial RPC timeout value of
@@ -491,7 +491,7 @@ public final org.threeten.bp.Duration getInitialRpcTimeout() {
491491
* the connection has been terminated).
492492
*
493493
* <p>{@link #getTotalTimeout()} caps how long the logic should keep trying the RPC until it
494-
* gives up completely. If {@link #getTotalTimeout()} is set, initialRpcTimeout should be <=
494+
* gives up completely. If {@link #getTotalTimeout()} is set, initialRpcTimeout should be &lt;=
495495
* totalTimeout.
496496
*
497497
* <p>If there are no configurations, Retries have the default initial RPC timeout value of

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/AttemptCallable.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ public ResponseT call() {
9898
UnauthenticatedException newEx =
9999
new UnauthenticatedException(
100100
unauthenticatedException.getMessage(),
101-
unauthenticatedException,
101+
unauthenticatedException.getCause(),
102102
unauthenticatedException.getStatusCode(),
103103
true, // isRetryable = true
104104
unauthenticatedException.getErrorDetails());

0 commit comments

Comments
 (0)