Skip to content

Commit 86fb459

Browse files
authored
test(auth): fix flaky refreshCredentialsIfRequired_blockingMultiThread test (#14704)
Fixes: #12573
1 parent 57eb5b4 commit 86fb459

1 file changed

Lines changed: 105 additions & 43 deletions

File tree

‎google-auth-library-java/cab-token-generator/javatests/com/google/auth/credentialaccessboundary/ClientSideCredentialAccessBoundaryFactoryTest.java‎

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import java.util.Base64;
7070
import java.util.Map;
7171
import java.util.concurrent.CountDownLatch;
72+
import java.util.concurrent.TimeUnit;
7273
import org.junit.jupiter.api.BeforeEach;
7374
import org.junit.jupiter.api.Test;
7475

@@ -82,23 +83,35 @@ class ClientSideCredentialAccessBoundaryFactoryTest {
8283
+ "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12i"
8384
+ "kv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0"
8485
+ "zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw"
85-
+ "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr"
86-
+ "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6"
87-
+ "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP"
88-
+ "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut"
89-
+ "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA"
90-
+ "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ"
91-
+ "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ"
92-
+ "==\n-----END PRIVATE KEY-----\n";
86+
+ "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/GrCtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrPSXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAutLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEAgidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ==\n"
87+
+ "-----END PRIVATE KEY-----\n";
9388

9489
private MockStsTransportFactory mockStsTransportFactory;
9590
private static MockTokenServerTransportFactory mockTokenServerTransportFactory;
9691

9792
static class MockStsTransportFactory implements HttpTransportFactory {
9893
MockStsTransport transport = new MockStsTransport();
94+
private CountDownLatch refreshEntryLatch = new CountDownLatch(0);
95+
96+
void setRefreshEntryLatch(CountDownLatch refreshEntryLatch) {
97+
this.refreshEntryLatch = refreshEntryLatch;
98+
}
9999

100100
@Override
101101
public HttpTransport create() {
102+
// Request 0 is the initial token fetch. On subsequent refresh requests (requestCount >= 1)
103+
// in multi-threaded tests, pause the in-flight STS request until all concurrent threads have
104+
// evaluated determineRefreshType() and attached to the existing refreshTask.
105+
if (transport.getRequestCount() >= 1) {
106+
try {
107+
if (!refreshEntryLatch.await(5, TimeUnit.SECONDS)) {
108+
throw new AssertionError("Timed out waiting for concurrent threads to coalesce");
109+
}
110+
} catch (InterruptedException e) {
111+
Thread.currentThread().interrupt();
112+
throw new RuntimeException(e);
113+
}
114+
}
102115
return transport;
103116
}
104117
}
@@ -305,8 +318,20 @@ void refreshCredentialsIfRequired_asyncSingleThread() throws IOException {
305318

306319
@Test
307320
void refreshCredentialsIfRequired_blockingMultiThread() throws IOException, InterruptedException {
321+
int numThreads = 3;
322+
// refreshCredentialsIfRequired() uses double-checked locking, calling determineRefreshType()
323+
// (and thus clock.currentTimeMillis()) twice per thread:
324+
// 1. Once outside synchronized (refreshLock) as a fast-path check.
325+
// 2. Once inside synchronized (refreshLock) immediately before getOrCreateRefreshTask().
326+
// Counting down on each clock.currentTimeMillis() call and waiting for numThreads * 2 calls in
327+
// MockStsTransportFactory.create() pauses the 1st thread's in-flight STS request until the
328+
// last thread has made its 2nd clock call inside synchronized (refreshLock). Because
329+
// finishRefreshTask() must also acquire refreshLock to clear refreshTask = null, the 1st
330+
// thread cannot clear the in-flight task until the last thread has called
331+
// getOrCreateRefreshTask() and attached to it.
332+
CountDownLatch refreshEntryLatch = new CountDownLatch(numThreads * 2);
308333
final ClientSideCredentialAccessBoundaryFactory factory =
309-
getClientSideCredentialAccessBoundaryFactory(RefreshType.BLOCKING);
334+
getClientSideCredentialAccessBoundaryFactory(RefreshType.BLOCKING, refreshEntryLatch);
310335

311336
// Call refreshCredentialsIfRequired() once to initialize the intermediate credentials. This
312337
// should make one request to the STS endpoint.
@@ -316,7 +341,7 @@ void refreshCredentialsIfRequired_blockingMultiThread() throws IOException, Inte
316341
// Simulate multiple threads concurrently calling refreshCredentialsIfRequired(). In blocking
317342
// mode, only one of these calls should trigger a new request to the STS endpoint. The others
318343
// should block until the first refresh completes and then use the newly acquired credentials.
319-
triggerConcurrentRefresh(factory, 3);
344+
triggerConcurrentRefresh(factory, numThreads, refreshEntryLatch);
320345

321346
// After all threads complete, the request count should be 2 (the initial fetch plus one
322347
// blocking refresh).
@@ -325,8 +350,12 @@ void refreshCredentialsIfRequired_blockingMultiThread() throws IOException, Inte
325350

326351
@Test
327352
void refreshCredentialsIfRequired_asyncMultiThread() throws IOException, InterruptedException {
353+
int numThreads = 5;
354+
// Wait for all 5 threads to make both clock.currentTimeMillis() calls (outside and inside
355+
// synchronized (refreshLock)) before the background STS refresh completes.
356+
CountDownLatch refreshEntryLatch = new CountDownLatch(numThreads * 2);
328357
final ClientSideCredentialAccessBoundaryFactory factory =
329-
getClientSideCredentialAccessBoundaryFactory(RefreshType.ASYNC);
358+
getClientSideCredentialAccessBoundaryFactory(RefreshType.ASYNC, refreshEntryLatch);
330359

331360
// Call refreshCredentialsIfRequired() once to initialize the intermediate credentials. This
332361
// should make one request to the STS endpoint.
@@ -336,7 +365,7 @@ void refreshCredentialsIfRequired_asyncMultiThread() throws IOException, Interru
336365
// Simulate multiple threads concurrently calling refreshCredentialsIfRequired(). In async
337366
// mode, the first call should trigger a background refresh. Subsequent calls should NOT
338367
// trigger additional refreshes while the background refresh is still pending.
339-
triggerConcurrentRefresh(factory, 5);
368+
triggerConcurrentRefresh(factory, numThreads, refreshEntryLatch);
340369

341370
// Introduce a small delay to allow the asynchronous refresh task to complete.
342371
try {
@@ -471,7 +500,8 @@ void builder_universeDomainMismatch_throws() throws IOException {
471500
.setUniverseDomain("differentUniverseDomain")
472501
.build());
473502
assertEquals(
474-
"The client side access boundary credential's universe domain must be the same as the source credential.",
503+
"The client side access boundary credential's universe domain must be the same as the"
504+
+ " source credential.",
475505
exception.getMessage());
476506
}
477507

@@ -583,17 +613,26 @@ private static GoogleCredentials getServiceAccountSourceCredentials(
583613

584614
private ClientSideCredentialAccessBoundaryFactory getClientSideCredentialAccessBoundaryFactory(
585615
RefreshType refreshType) throws IOException {
616+
return getClientSideCredentialAccessBoundaryFactory(refreshType, new CountDownLatch(0));
617+
}
618+
619+
private ClientSideCredentialAccessBoundaryFactory getClientSideCredentialAccessBoundaryFactory(
620+
RefreshType refreshType, CountDownLatch refreshEntryLatch) throws IOException {
586621
GoogleCredentials sourceCredentials =
587622
getServiceAccountSourceCredentials(mockTokenServerTransportFactory);
623+
mockStsTransportFactory.setRefreshEntryLatch(refreshEntryLatch);
588624

589625
return ClientSideCredentialAccessBoundaryFactory.newBuilder()
590626
.setSourceCredential(sourceCredentials)
591627
.setHttpTransportFactory(mockStsTransportFactory)
592-
.setClock(createMockClock(refreshType, sourceCredentials))
628+
.setClock(createMockClock(refreshType, sourceCredentials, refreshEntryLatch))
593629
.build();
594630
}
595631

596-
private Clock createMockClock(RefreshType refreshType, GoogleCredentials sourceCredentials) {
632+
private Clock createMockClock(
633+
RefreshType refreshType,
634+
GoogleCredentials sourceCredentials,
635+
CountDownLatch refreshEntryLatch) {
597636
Clock mockClock = mock(Clock.class);
598637
long currentTimeInMillis = Clock.SYSTEM.currentTimeMillis();
599638
long mockedTimeInMillis;
@@ -623,6 +662,7 @@ private Clock createMockClock(RefreshType refreshType, GoogleCredentials sourceC
623662
when(mockClock.currentTimeMillis())
624663
.thenAnswer(
625664
invocation -> {
665+
refreshEntryLatch.countDown();
626666
// If the async refresh has already been triggered (request count >= 2),
627667
// return the fresh time to skip redundant refreshes.
628668
// Note: 1st request was the initial blocking refresh.
@@ -636,7 +676,12 @@ private Clock createMockClock(RefreshType refreshType, GoogleCredentials sourceC
636676
// Set mocked time so that the token requires immediate refresh (just after the minimum
637677
// token lifetime).
638678
mockedTimeInMillis = expirationTimeInMillis - minimumTokenLifetimeMillis + 60000;
639-
when(mockClock.currentTimeMillis()).thenReturn(mockedTimeInMillis);
679+
when(mockClock.currentTimeMillis())
680+
.thenAnswer(
681+
invocation -> {
682+
refreshEntryLatch.countDown();
683+
return mockedTimeInMillis;
684+
});
640685
break;
641686
default:
642687
throw new IllegalArgumentException("Unexpected RefreshType: " + refreshType);
@@ -646,39 +691,56 @@ private Clock createMockClock(RefreshType refreshType, GoogleCredentials sourceC
646691
}
647692

648693
private static void triggerConcurrentRefresh(
649-
ClientSideCredentialAccessBoundaryFactory factory, int numThreads)
694+
ClientSideCredentialAccessBoundaryFactory factory,
695+
int numThreads,
696+
CountDownLatch refreshEntryLatch)
650697
throws InterruptedException {
651698
Thread[] threads = new Thread[numThreads];
652699
CountDownLatch latch = new CountDownLatch(numThreads);
653700
long timeoutMillis = 5000; // 5 seconds
654701

655-
// Create and start threads to concurrently call refreshCredentialsIfRequired().
656-
for (int i = 0; i < numThreads; i++) {
657-
threads[i] =
658-
new Thread(
659-
() -> {
660-
try {
661-
latch.countDown();
662-
latch.await();
663-
factory.refreshCredentialsIfRequired();
664-
} catch (InterruptedException e) {
665-
Thread.currentThread().interrupt();
666-
} catch (IOException e) {
667-
throw new RuntimeException(e);
668-
}
669-
});
670-
threads[i].start();
671-
}
702+
try {
703+
// Create and start threads to concurrently call refreshCredentialsIfRequired().
704+
for (int i = 0; i < numThreads; i++) {
705+
threads[i] =
706+
new Thread(
707+
() -> {
708+
try {
709+
// Act as a starting gate: signal that this thread is spawned and ready, then
710+
// wait until all other threads have also started before proceeding together.
711+
latch.countDown();
712+
latch.await();
713+
factory.refreshCredentialsIfRequired();
714+
} catch (InterruptedException e) {
715+
Thread.currentThread().interrupt();
716+
} catch (IOException e) {
717+
throw new RuntimeException(e);
718+
}
719+
});
720+
threads[i].start();
721+
}
672722

673-
// Wait for each thread to complete, with a timeout.
674-
for (Thread thread : threads) {
675-
thread.join(timeoutMillis);
676-
if (thread.isAlive()) {
677-
thread.interrupt();
678-
throw new AssertionError(
679-
"Thread running refreshCredentialsIfRequired timed out after "
680-
+ timeoutMillis
681-
+ " milliseconds.");
723+
// Wait for each thread to complete, with a timeout.
724+
for (Thread thread : threads) {
725+
thread.join(timeoutMillis);
726+
// Thread.join(long) returns silently when the timeout expires without throwing an
727+
// exception. Check isAlive() to detect if the thread timed out and is still hung.
728+
if (thread.isAlive()) {
729+
thread.interrupt();
730+
throw new AssertionError(
731+
"Thread running refreshCredentialsIfRequired timed out after "
732+
+ timeoutMillis
733+
+ " milliseconds.");
734+
}
735+
}
736+
} finally {
737+
while (refreshEntryLatch.getCount() > 0) {
738+
refreshEntryLatch.countDown();
739+
}
740+
for (Thread thread : threads) {
741+
if (thread != null && thread.isAlive()) {
742+
thread.interrupt();
743+
}
682744
}
683745
}
684746
}

0 commit comments

Comments
 (0)