@@ -115,11 +115,14 @@ public void accessorInitialization() {
115115 * Regression test for <a href="https://github.com/Azure/azure-sdk-for-java/issues/48622">#48622</a>
116116 * and <a href="https://github.com/Azure/azure-sdk-for-java/issues/48585">#48585</a>.
117117 * <p>
118- * Forks a fresh JVM that concurrently triggers {@code <clinit>} of different Cosmos classes
119- * from 6 threads. In a fresh JVM, {@code <clinit>} runs for the first time — the only way
120- * to exercise the real deadlock scenario. A 30-second timeout detects the hang.
118+ * Forks a fresh JVM that concurrently triggers {@code <clinit>} of 12 different Cosmos classes
119+ * from 12 threads synchronized via a {@link CyclicBarrier}. In a fresh JVM, {@code <clinit>}
120+ * runs for the first time — the only way to exercise the real deadlock scenario. A 30-second
121+ * timeout detects the hang. Runs 5 invocations via TestNG ({@code invocationCount = 5}),
122+ * each forking 3 child JVMs — totaling 15 fresh JVMs × 12 concurrent threads = 180
123+ * {@code <clinit>} race attempts.
121124 */
122- @ Test (groups = { "unit" })
125+ @ Test (groups = { "unit" }, invocationCount = 5 )
123126 public void concurrentAccessorInitializationShouldNotDeadlock () throws Exception {
124127
125128 String javaHome = System .getProperty ("java.home" );
@@ -148,27 +151,39 @@ public void concurrentAccessorInitializationShouldNotDeadlock() throws Exception
148151 int runs = 3 ;
149152
150153 for (int run = 1 ; run <= runs ; run ++) {
154+ final int currentRun = run ;
151155 ProcessBuilder pb = new ProcessBuilder (command );
152156 pb .redirectErrorStream (true );
153157 Process process = pb .start ();
154158
159+ // Drain stdout on a separate thread to prevent blocking if child JVM deadlocks.
160+ // Without this, readLine() would block indefinitely and the timeout below
161+ // would never be reached.
155162 StringBuilder output = new StringBuilder ();
156- try (BufferedReader reader = new BufferedReader (new InputStreamReader (process .getInputStream ()))) {
157- String line ;
158- while ((line = reader .readLine ()) != null ) {
159- output .append (line ).append (System .lineSeparator ());
160- logger .info ("[child-jvm-run-{}] {}" , run , line );
163+ Thread gobbler = new Thread (() -> {
164+ try (BufferedReader reader = new BufferedReader (new InputStreamReader (process .getInputStream ()))) {
165+ String line ;
166+ while ((line = reader .readLine ()) != null ) {
167+ output .append (line ).append (System .lineSeparator ());
168+ logger .info ("[child-jvm-run-{}] {}" , currentRun , line );
169+ }
170+ } catch (Exception e ) {
171+ // Process was destroyed — expected on timeout
161172 }
162- }
173+ });
174+ gobbler .setDaemon (true );
175+ gobbler .start ();
163176
164177 boolean completed = process .waitFor (timeoutSeconds , TimeUnit .SECONDS );
165178
166179 if (!completed ) {
167180 process .destroyForcibly ();
181+ gobbler .join (5000 );
168182 fail ("Run " + run + ": Child JVM did not complete within " + timeoutSeconds
169183 + " seconds — <clinit> deadlock detected" );
170184 }
171185
186+ gobbler .join (5000 );
172187 int exitCode = process .exitValue ();
173188 assertThat (exitCode )
174189 .as ("Run " + run + ": Child JVM exited with non-zero code. Output:\n " + output )
@@ -177,58 +192,258 @@ public void concurrentAccessorInitializationShouldNotDeadlock() throws Exception
177192 }
178193
179194 /**
180- * Entry point for the forked child JVM. Concurrently triggers {@code <clinit>} of 6 different
195+ * Entry point for the forked child JVM. Concurrently triggers {@code <clinit>} of 12 different
181196 * Cosmos classes that are involved in the circular initialization chain reported in the issues.
182- * Exits 0 on success, 1 on deadlock (timeout), 2 on unexpected error .
197+ * Exits 0 on success, 1 on deadlock (timeout).
183198 */
184199 public static final class ConcurrentClinitChildProcess {
185200 public static void main (String [] args ) {
186201 int timeoutSeconds = 20 ;
187- int threadCount = 6 ;
188- CyclicBarrier barrier = new CyclicBarrier (threadCount );
189- ExecutorService executor = Executors .newFixedThreadPool (threadCount );
190202
191203 String [] classesToLoad = {
192204 "com.azure.cosmos.CosmosAsyncClient" ,
193205 "com.azure.cosmos.models.SqlParameter" ,
194206 "com.azure.cosmos.models.FeedResponse" ,
195207 "com.azure.cosmos.models.CosmosItemRequestOptions" ,
196208 "com.azure.cosmos.CosmosAsyncContainer" ,
197- "com.azure.cosmos.util.CosmosPagedFluxDefaultImpl"
209+ "com.azure.cosmos.util.CosmosPagedFluxDefaultImpl" ,
210+ "com.azure.cosmos.CosmosClientBuilder" ,
211+ "com.azure.cosmos.CosmosItemSerializer" ,
212+ "com.azure.cosmos.CosmosDiagnostics" ,
213+ "com.azure.cosmos.CosmosDiagnosticsContext" ,
214+ "com.azure.cosmos.models.CosmosQueryRequestOptions" ,
215+ "com.azure.cosmos.models.CosmosChangeFeedRequestOptions"
198216 };
199217
200- List <Future <?>> futures = new ArrayList <>();
201- for (int i = 0 ; i < classesToLoad .length ; i ++) {
202- final String className = classesToLoad [i ];
203- final int idx = i ;
204- futures .add (executor .submit (() -> {
218+ int threadCount = classesToLoad .length ;
219+
220+ // CyclicBarrier ensures all threads release at the exact same instant,
221+ // maximizing the probability of concurrent <clinit> collisions. Without it,
222+ // thread startup stagger means earlier threads may finish <clinit> before
223+ // later threads start — hiding the deadlock.
224+ CyclicBarrier barrier = new CyclicBarrier (threadCount );
225+ ExecutorService executor = Executors .newFixedThreadPool (threadCount );
226+
227+ try {
228+ List <Future <?>> futures = new ArrayList <>();
229+ for (int i = 0 ; i < classesToLoad .length ; i ++) {
230+ final String className = classesToLoad [i ];
231+ final int idx = i ;
232+ futures .add (executor .submit (() -> {
233+ try {
234+ barrier .await ();
235+ System .out .println ("[Thread-" + idx + "] Loading " + className );
236+ Class .forName (className );
237+ System .out .println ("[Thread-" + idx + "] Done." );
238+ } catch (Exception e ) {
239+ throw new RuntimeException ("Failed to load " + className , e );
240+ }
241+ }));
242+ }
243+
244+ boolean deadlock = false ;
245+ for (int i = 0 ; i < futures .size (); i ++) {
205246 try {
206- barrier . await ( );
207- System . out . println ( "[Thread-" + idx + "] Loading " + className );
208- Class . forName ( className );
209- System . out . println ( "[Thread-" + idx + "] Done." ) ;
247+ futures . get ( i ). get ( timeoutSeconds , TimeUnit . SECONDS );
248+ } catch ( java . util . concurrent . TimeoutException e ) {
249+ System . err . println ( "DEADLOCK: Thread-" + i + " timed out after " + timeoutSeconds + "s" );
250+ deadlock = true ;
210251 } catch (Exception e ) {
211- throw new RuntimeException ("Failed to load " + className , e );
252+ Throwable root = e ;
253+ while (root .getCause () != null ) {
254+ root = root .getCause ();
255+ }
256+ System .err .println ("Thread-" + i + " error: " + root );
257+ }
258+ }
259+
260+ if (deadlock ) {
261+ System .exit (1 );
262+ }
263+
264+ // Verify all classes are actually initialized
265+ for (String className : classesToLoad ) {
266+ try {
267+ // Class.forName with initialize=false just checks if already loaded
268+ // If the class was loaded above, this returns immediately
269+ Class <?> cls = Class .forName (className , false ,
270+ ConcurrentClinitChildProcess .class .getClassLoader ());
271+ // Verify the class is initialized by accessing its static state
272+ // (calling a static method would trigger <clinit> if not done,
273+ // but we explicitly check it's already done)
274+ System .out .println ("Verified loaded: " + cls .getName ());
275+ } catch (ClassNotFoundException e ) {
276+ System .err .println ("Class not loaded: " + className );
277+ System .exit (1 );
212278 }
213- }));
279+ }
280+
281+ System .exit (0 );
282+ } finally {
283+ executor .shutdownNow ();
214284 }
285+ }
286+ }
287+
288+ /**
289+ * Enforces that every class targeted by {@code ensureClassInitialized()} in
290+ * {@link ImplementationBridgeHelpers} registers its accessor during {@code <clinit>}
291+ * (i.e., has a {@code static { initialize(); }} block).
292+ * <p>
293+ * Verification is behavioral, not source-based: a forked child JVM iterates every
294+ * {@code *Helper} inner class, calls each {@code getXxxAccessor()} getter (which triggers
295+ * {@code Class.forName()} → {@code <clinit>}), and checks the accessor is non-null
296+ * via reflection. If a class is missing {@code static { initialize(); }}, the accessor
297+ * remains null and this test fails.
298+ */
299+ @ Test (groups = { "unit" })
300+ public void allAccessorClassesMustHaveStaticInitializerBlock () throws Exception {
301+ String javaHome = System .getProperty ("java.home" );
302+ String javaBin = javaHome + java .io .File .separator + "bin" + java .io .File .separator + "java" ;
303+ String classpath = System .getProperty ("java.class.path" );
304+
305+ List <String > command = new ArrayList <>();
306+ command .add (javaBin );
307+
308+ try {
309+ int majorVersion = Integer .parseInt (System .getProperty ("java.specification.version" ).split ("\\ ." )[0 ]);
310+ if (majorVersion >= 9 ) {
311+ command .add ("--add-opens" );
312+ command .add ("java.base/java.lang=ALL-UNNAMED" );
313+ }
314+ } catch (NumberFormatException e ) {
315+ // JDK 8
316+ }
317+
318+ command .add ("-cp" );
319+ command .add (classpath );
320+ command .add (AccessorRegistrationChildProcess .class .getName ());
321+
322+ ProcessBuilder pb = new ProcessBuilder (command );
323+ pb .redirectErrorStream (true );
324+ Process process = pb .start ();
325+
326+ StringBuilder output = new StringBuilder ();
327+ Thread gobbler = new Thread (() -> {
328+ try (BufferedReader reader = new BufferedReader (new InputStreamReader (process .getInputStream ()))) {
329+ String line ;
330+ while ((line = reader .readLine ()) != null ) {
331+ output .append (line ).append (System .lineSeparator ());
332+ logger .info ("[accessor-check] {}" , line );
333+ }
334+ } catch (Exception e ) {
335+ // Process destroyed
336+ }
337+ });
338+ gobbler .setDaemon (true );
339+ gobbler .start ();
340+
341+ boolean completed = process .waitFor (60 , TimeUnit .SECONDS );
342+ if (!completed ) {
343+ process .destroyForcibly ();
344+ gobbler .join (5000 );
345+ fail ("Accessor registration check timed out after 60s. Output:\n " + output );
346+ }
347+
348+ gobbler .join (5000 );
349+ int exitCode = process .exitValue ();
350+ assertThat (exitCode )
351+ .as ("Some accessor classes don't register their accessor during <clinit>. Output:\n " + output )
352+ .isEqualTo (0 );
353+ }
354+
355+ /**
356+ * Child process that verifies every {@code *Helper} inner class in
357+ * {@link ImplementationBridgeHelpers} has its accessor registered after calling the
358+ * corresponding {@code getXxxAccessor()} getter. Runs in a fresh JVM where no Cosmos
359+ * classes have been loaded yet, so {@code <clinit>} is triggered for the first time.
360+ */
361+ public static final class AccessorRegistrationChildProcess {
362+ public static void main (String [] args ) throws Exception {
363+ // Iterate all *Helper inner classes in ImplementationBridgeHelpers.
364+ // For each, call the getXxxAccessor() getter which triggers ensureClassInitialized()
365+ // → Class.forName() → <clinit>. Then verify the accessor field is non-null.
366+
367+ Class <?>[] helpers = ImplementationBridgeHelpers .class .getDeclaredClasses ();
368+ List <String > failures = new ArrayList <>();
369+
370+ for (Class <?> helper : helpers ) {
371+ if (!helper .getSimpleName ().endsWith ("Helper" )) {
372+ continue ;
373+ }
374+
375+ // Find the accessor AtomicReference field
376+ Field accessorField = null ;
377+ Field classLoadedField = null ;
378+ for (Field f : helper .getDeclaredFields ()) {
379+ if (f .getName ().contains ("accessor" ) && f .getType () == AtomicReference .class ) {
380+ accessorField = f ;
381+ }
382+ if (f .getName ().contains ("ClassLoaded" ) && f .getType () == AtomicBoolean .class ) {
383+ classLoadedField = f ;
384+ }
385+ }
386+
387+ if (accessorField == null || classLoadedField == null ) {
388+ continue ;
389+ }
390+
391+ // Check if the accessor is already set (from transitive <clinit> of earlier classes)
392+ accessorField .setAccessible (true );
393+ AtomicReference <?> ref = (AtomicReference <?>) accessorField .get (null );
394+ if (ref .get () != null ) {
395+ System .out .println ("OK (already loaded): " + helper .getSimpleName ());
396+ continue ;
397+ }
398+
399+ // Find the target class name by looking for a getXxxAccessor method that calls
400+ // ensureClassInitialized. We can't easily extract the string constant, so instead
401+ // we call the getter and check if the accessor becomes non-null.
402+ // The getter calls ensureClassInitialized() → Class.forName() → <clinit>.
403+ // If <clinit> calls initialize(), the accessor is registered.
404+ java .lang .reflect .Method getterMethod = null ;
405+ for (java .lang .reflect .Method m : helper .getDeclaredMethods ()) {
406+ if (m .getName ().startsWith ("get" ) && m .getName ().endsWith ("Accessor" )
407+ && m .getParameterCount () == 0
408+ && java .lang .reflect .Modifier .isStatic (m .getModifiers ())) {
409+ getterMethod = m ;
410+ break ;
411+ }
412+ }
413+
414+ if (getterMethod == null ) {
415+ continue ;
416+ }
215417
216- boolean deadlock = false ;
217- for (int i = 0 ; i < futures .size (); i ++) {
218418 try {
219- futures .get (i ).get (timeoutSeconds , TimeUnit .SECONDS );
220- } catch (java .util .concurrent .TimeoutException e ) {
221- System .err .println ("DEADLOCK: Thread-" + i + " timed out after " + timeoutSeconds + "s" );
222- deadlock = true ;
419+ Object result = getterMethod .invoke (null );
420+ if (result == null ) {
421+ failures .add (helper .getSimpleName () + ": accessor is null after getter call — "
422+ + "target class <clinit> does not call initialize()" );
423+ } else {
424+ System .out .println ("OK: " + helper .getSimpleName ());
425+ }
223426 } catch (Exception e ) {
224427 Throwable root = e ;
225- while (root .getCause () != null ) root = root .getCause ();
226- System .err .println ("Thread-" + i + " error: " + root );
428+ while (root .getCause () != null ) {
429+ root = root .getCause ();
430+ }
431+ failures .add (helper .getSimpleName () + ": " + root .getClass ().getSimpleName ()
432+ + " — " + root .getMessage ());
227433 }
228434 }
229435
230- executor .shutdownNow ();
231- System .exit (deadlock ? 1 : 0 );
436+ if (failures .isEmpty ()) {
437+ System .out .println ("All accessor classes register their accessor during <clinit>." );
438+ System .exit (0 );
439+ } else {
440+ System .err .println ("FAILURES — the following classes do not register their accessor "
441+ + "during <clinit> (missing 'static { initialize(); }' block):" );
442+ for (String f : failures ) {
443+ System .err .println (" " + f );
444+ }
445+ System .exit (1 );
446+ }
232447 }
233448 }
234449}
0 commit comments