6969import java .util .Base64 ;
7070import java .util .Map ;
7171import java .util .concurrent .CountDownLatch ;
72+ import java .util .concurrent .TimeUnit ;
7273import org .junit .jupiter .api .BeforeEach ;
7374import 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