@@ -119,7 +119,7 @@ public void accessorInitialization() {
119119 * from 6 threads. In a fresh JVM, {@code <clinit>} runs for the first time — the only way
120120 * to exercise the real deadlock scenario. A 30-second timeout detects the hang.
121121 */
122- @ Test (groups = { "unit" })
122+ @ Test (groups = { "unit" }, invocationCount = 5 )
123123 public void concurrentAccessorInitializationShouldNotDeadlock () throws Exception {
124124
125125 String javaHome = System .getProperty ("java.home" );
@@ -148,27 +148,39 @@ public void concurrentAccessorInitializationShouldNotDeadlock() throws Exception
148148 int runs = 3 ;
149149
150150 for (int run = 1 ; run <= runs ; run ++) {
151+ final int currentRun = run ;
151152 ProcessBuilder pb = new ProcessBuilder (command );
152153 pb .redirectErrorStream (true );
153154 Process process = pb .start ();
154155
156+ // Drain stdout on a separate thread to prevent blocking if child JVM deadlocks.
157+ // Without this, readLine() would block indefinitely and the timeout below
158+ // would never be reached.
155159 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 );
160+ Thread gobbler = new Thread (() -> {
161+ try (BufferedReader reader = new BufferedReader (new InputStreamReader (process .getInputStream ()))) {
162+ String line ;
163+ while ((line = reader .readLine ()) != null ) {
164+ output .append (line ).append (System .lineSeparator ());
165+ logger .info ("[child-jvm-run-{}] {}" , currentRun , line );
166+ }
167+ } catch (Exception e ) {
168+ // Process was destroyed — expected on timeout
161169 }
162- }
170+ });
171+ gobbler .setDaemon (true );
172+ gobbler .start ();
163173
164174 boolean completed = process .waitFor (timeoutSeconds , TimeUnit .SECONDS );
165175
166176 if (!completed ) {
167177 process .destroyForcibly ();
178+ gobbler .join (5000 );
168179 fail ("Run " + run + ": Child JVM did not complete within " + timeoutSeconds
169180 + " seconds — <clinit> deadlock detected" );
170181 }
171182
183+ gobbler .join (5000 );
172184 int exitCode = process .exitValue ();
173185 assertThat (exitCode )
174186 .as ("Run " + run + ": Child JVM exited with non-zero code. Output:\n " + output )
@@ -184,51 +196,249 @@ public void concurrentAccessorInitializationShouldNotDeadlock() throws Exception
184196 public static final class ConcurrentClinitChildProcess {
185197 public static void main (String [] args ) {
186198 int timeoutSeconds = 20 ;
187- int threadCount = 6 ;
188- CyclicBarrier barrier = new CyclicBarrier (threadCount );
189- ExecutorService executor = Executors .newFixedThreadPool (threadCount );
190199
191200 String [] classesToLoad = {
192201 "com.azure.cosmos.CosmosAsyncClient" ,
193202 "com.azure.cosmos.models.SqlParameter" ,
194203 "com.azure.cosmos.models.FeedResponse" ,
195204 "com.azure.cosmos.models.CosmosItemRequestOptions" ,
196205 "com.azure.cosmos.CosmosAsyncContainer" ,
197- "com.azure.cosmos.util.CosmosPagedFluxDefaultImpl"
206+ "com.azure.cosmos.util.CosmosPagedFluxDefaultImpl" ,
207+ "com.azure.cosmos.CosmosClientBuilder" ,
208+ "com.azure.cosmos.CosmosItemSerializer" ,
209+ "com.azure.cosmos.CosmosDiagnostics" ,
210+ "com.azure.cosmos.CosmosDiagnosticsContext" ,
211+ "com.azure.cosmos.models.CosmosQueryRequestOptions" ,
212+ "com.azure.cosmos.models.CosmosChangeFeedRequestOptions"
198213 };
199214
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 (() -> {
215+ int threadCount = classesToLoad .length ;
216+
217+ // CyclicBarrier ensures all threads release at the exact same instant,
218+ // maximizing the probability of concurrent <clinit> collisions. Without it,
219+ // thread startup stagger means earlier threads may finish <clinit> before
220+ // later threads start — hiding the deadlock.
221+ CyclicBarrier barrier = new CyclicBarrier (threadCount );
222+ ExecutorService executor = Executors .newFixedThreadPool (threadCount );
223+
224+ try {
225+ List <Future <?>> futures = new ArrayList <>();
226+ for (int i = 0 ; i < classesToLoad .length ; i ++) {
227+ final String className = classesToLoad [i ];
228+ final int idx = i ;
229+ futures .add (executor .submit (() -> {
230+ try {
231+ barrier .await ();
232+ System .out .println ("[Thread-" + idx + "] Loading " + className );
233+ Class .forName (className );
234+ System .out .println ("[Thread-" + idx + "] Done." );
235+ } catch (Exception e ) {
236+ throw new RuntimeException ("Failed to load " + className , e );
237+ }
238+ }));
239+ }
240+
241+ boolean deadlock = false ;
242+ for (int i = 0 ; i < futures .size (); i ++) {
205243 try {
206- barrier . await ( );
207- System . out . println ( "[Thread-" + idx + "] Loading " + className );
208- Class . forName ( className );
209- System . out . println ( "[Thread-" + idx + "] Done." ) ;
244+ futures . get ( i ). get ( timeoutSeconds , TimeUnit . SECONDS );
245+ } catch ( java . util . concurrent . TimeoutException e ) {
246+ System . err . println ( "DEADLOCK: Thread-" + i + " timed out after " + timeoutSeconds + "s" );
247+ deadlock = true ;
210248 } catch (Exception e ) {
211- throw new RuntimeException ("Failed to load " + className , e );
249+ Throwable root = e ;
250+ while (root .getCause () != null ) {
251+ root = root .getCause ();
252+ }
253+ System .err .println ("Thread-" + i + " error: " + root );
254+ }
255+ }
256+
257+ if (deadlock ) {
258+ System .exit (1 );
259+ }
260+
261+ // Verify all classes are actually initialized
262+ for (String className : classesToLoad ) {
263+ try {
264+ // Class.forName with initialize=false just checks if already loaded
265+ // If the class was loaded above, this returns immediately
266+ Class <?> cls = Class .forName (className , false ,
267+ ConcurrentClinitChildProcess .class .getClassLoader ());
268+ // Verify the class is initialized by accessing its static state
269+ // (calling a static method would trigger <clinit> if not done,
270+ // but we explicitly check it's already done)
271+ System .out .println ("Verified loaded: " + cls .getName ());
272+ } catch (ClassNotFoundException e ) {
273+ System .err .println ("Class not loaded: " + className );
274+ System .exit (1 );
212275 }
213- }));
276+ }
277+
278+ System .exit (0 );
279+ } finally {
280+ executor .shutdownNow ();
214281 }
282+ }
283+ }
284+
285+ /**
286+ * Enforces that every class targeted by {@code ensureClassInitialized()} in
287+ * {@link ImplementationBridgeHelpers} registers its accessor during {@code <clinit>}
288+ * (i.e., has a {@code static { initialize(); }} block).
289+ * <p>
290+ * Verification is behavioral, not source-based: a forked child JVM loads each target
291+ * class via {@code Class.forName()}, then uses reflection to check the corresponding
292+ * Helper's accessor field is non-null. If {@code <clinit>} doesn't call
293+ * {@code initialize()}, the accessor remains null and the test fails.
294+ */
295+ @ Test (groups = { "unit" })
296+ public void allAccessorClassesMustHaveStaticInitializerBlock () throws Exception {
297+ String javaHome = System .getProperty ("java.home" );
298+ String javaBin = javaHome + java .io .File .separator + "bin" + java .io .File .separator + "java" ;
299+ String classpath = System .getProperty ("java.class.path" );
300+
301+ List <String > command = new ArrayList <>();
302+ command .add (javaBin );
303+
304+ try {
305+ int majorVersion = Integer .parseInt (System .getProperty ("java.specification.version" ).split ("\\ ." )[0 ]);
306+ if (majorVersion >= 9 ) {
307+ command .add ("--add-opens" );
308+ command .add ("java.base/java.lang=ALL-UNNAMED" );
309+ }
310+ } catch (NumberFormatException e ) {
311+ // JDK 8
312+ }
313+
314+ command .add ("-cp" );
315+ command .add (classpath );
316+ command .add (AccessorRegistrationChildProcess .class .getName ());
317+
318+ ProcessBuilder pb = new ProcessBuilder (command );
319+ pb .redirectErrorStream (true );
320+ Process process = pb .start ();
321+
322+ StringBuilder output = new StringBuilder ();
323+ Thread gobbler = new Thread (() -> {
324+ try (BufferedReader reader = new BufferedReader (new InputStreamReader (process .getInputStream ()))) {
325+ String line ;
326+ while ((line = reader .readLine ()) != null ) {
327+ output .append (line ).append (System .lineSeparator ());
328+ logger .info ("[accessor-check] {}" , line );
329+ }
330+ } catch (Exception e ) {
331+ // Process destroyed
332+ }
333+ });
334+ gobbler .setDaemon (true );
335+ gobbler .start ();
336+
337+ boolean completed = process .waitFor (60 , TimeUnit .SECONDS );
338+ if (!completed ) {
339+ process .destroyForcibly ();
340+ gobbler .join (5000 );
341+ fail ("Accessor registration check timed out after 60s. Output:\n " + output );
342+ }
343+
344+ gobbler .join (5000 );
345+ int exitCode = process .exitValue ();
346+ assertThat (exitCode )
347+ .as ("Some accessor classes don't register their accessor during <clinit>. Output:\n " + output )
348+ .isEqualTo (0 );
349+ }
350+
351+ /**
352+ * Child process that verifies every class referenced by {@code ensureClassInitialized()}
353+ * registers its accessor during {@code <clinit>}. Runs in a fresh JVM where no Cosmos
354+ * classes have been loaded yet.
355+ */
356+ public static final class AccessorRegistrationChildProcess {
357+ public static void main (String [] args ) throws Exception {
358+ // Scan ImplementationBridgeHelpers for all Helper inner classes and their accessor fields.
359+ // For each Helper, find the matching ensureClassInitialized target class, load it via
360+ // Class.forName(), and verify the accessor field is non-null.
361+
362+ Class <?>[] helpers = ImplementationBridgeHelpers .class .getDeclaredClasses ();
363+ List <String > failures = new ArrayList <>();
364+
365+ for (Class <?> helper : helpers ) {
366+ if (!helper .getSimpleName ().endsWith ("Helper" )) {
367+ continue ;
368+ }
369+
370+ // Find the accessor AtomicReference field
371+ Field accessorField = null ;
372+ Field classLoadedField = null ;
373+ for (Field f : helper .getDeclaredFields ()) {
374+ if (f .getName ().contains ("accessor" ) && f .getType () == AtomicReference .class ) {
375+ accessorField = f ;
376+ }
377+ if (f .getName ().contains ("ClassLoaded" ) && f .getType () == AtomicBoolean .class ) {
378+ classLoadedField = f ;
379+ }
380+ }
381+
382+ if (accessorField == null || classLoadedField == null ) {
383+ continue ;
384+ }
385+
386+ // Check if the accessor is already set (from transitive <clinit> of earlier classes)
387+ accessorField .setAccessible (true );
388+ AtomicReference <?> ref = (AtomicReference <?>) accessorField .get (null );
389+ if (ref .get () != null ) {
390+ System .out .println ("OK (already loaded): " + helper .getSimpleName ());
391+ continue ;
392+ }
393+
394+ // Find the target class name by looking for a getXxxAccessor method that calls
395+ // ensureClassInitialized. We can't easily extract the string constant, so instead
396+ // we call the getter and check if the accessor becomes non-null.
397+ // The getter calls ensureClassInitialized() → Class.forName() → <clinit>.
398+ // If <clinit> calls initialize(), the accessor is registered.
399+ java .lang .reflect .Method getterMethod = null ;
400+ for (java .lang .reflect .Method m : helper .getDeclaredMethods ()) {
401+ if (m .getName ().startsWith ("get" ) && m .getName ().endsWith ("Accessor" )
402+ && m .getParameterCount () == 0
403+ && java .lang .reflect .Modifier .isStatic (m .getModifiers ())) {
404+ getterMethod = m ;
405+ break ;
406+ }
407+ }
408+
409+ if (getterMethod == null ) {
410+ continue ;
411+ }
215412
216- boolean deadlock = false ;
217- for (int i = 0 ; i < futures .size (); i ++) {
218413 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 ;
414+ Object result = getterMethod .invoke (null );
415+ if (result == null ) {
416+ failures .add (helper .getSimpleName () + ": accessor is null after getter call — "
417+ + "target class <clinit> does not call initialize()" );
418+ } else {
419+ System .out .println ("OK: " + helper .getSimpleName ());
420+ }
223421 } catch (Exception e ) {
224422 Throwable root = e ;
225- while (root .getCause () != null ) root = root .getCause ();
226- System .err .println ("Thread-" + i + " error: " + root );
423+ while (root .getCause () != null ) {
424+ root = root .getCause ();
425+ }
426+ failures .add (helper .getSimpleName () + ": " + root .getClass ().getSimpleName ()
427+ + " — " + root .getMessage ());
227428 }
228429 }
229430
230- executor .shutdownNow ();
231- System .exit (deadlock ? 1 : 0 );
431+ if (failures .isEmpty ()) {
432+ System .out .println ("All accessor classes register their accessor during <clinit>." );
433+ System .exit (0 );
434+ } else {
435+ System .err .println ("FAILURES — the following classes do not register their accessor "
436+ + "during <clinit> (missing 'static { initialize(); }' block):" );
437+ for (String f : failures ) {
438+ System .err .println (" " + f );
439+ }
440+ System .exit (1 );
441+ }
232442 }
233443 }
234444}
0 commit comments