@@ -229,6 +229,232 @@ public void delayedCallsRunUnderContext() throws Exception {
229229 assertThat (contextKey .get (readyContext .get ())).isEqualTo (goldenValue );
230230 }
231231
232+ @ Test
233+ public void listenerThrowsInPendingCallback_cancelsRealCall () {
234+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
235+ callExecutor , fakeClock .getScheduledExecutorService (), null );
236+ final RuntimeException boom = new RuntimeException ("boom" );
237+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
238+ @ Override
239+ public void onMessage (Integer msg ) {
240+ throw boom ;
241+ }
242+ };
243+ delayedClientCall .start (throwingListener , new Metadata ());
244+ // Deliver onMessage while the wrapping DelayedListener is still buffering, by firing
245+ // it from within realCall.start() — drainPendingCalls has not yet flipped the listener
246+ // to pass-through. The queued onMessage is then drained and throws; the fix must catch
247+ // the throwable and cancel the real call rather than let it escape.
248+ Runnable r = delayedClientCall .setCall (new SimpleForwardingClientCall <String , Integer >(
249+ mockRealCall ) {
250+ @ Override
251+ public void start (Listener <Integer > listener , Metadata metadata ) {
252+ super .start (listener , metadata );
253+ listener .onMessage (42 );
254+ }
255+ });
256+ assertThat (r ).isNotNull ();
257+ r .run (); // Must not propagate `boom`.
258+ verify (mockRealCall ).cancel (eq ("Failed to read message." ), eq (boom ));
259+ }
260+
261+ @ Test
262+ public void listenerThrowsInPendingOnHeaders_cancelsRealCall () {
263+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
264+ callExecutor , fakeClock .getScheduledExecutorService (), null );
265+ final RuntimeException boom = new RuntimeException ("boom" );
266+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
267+ @ Override
268+ public void onHeaders (Metadata headers ) {
269+ throw boom ;
270+ }
271+ };
272+ delayedClientCall .start (throwingListener , new Metadata ());
273+ Runnable r = delayedClientCall .setCall (new SimpleForwardingClientCall <String , Integer >(
274+ mockRealCall ) {
275+ @ Override
276+ public void start (Listener <Integer > listener , Metadata metadata ) {
277+ super .start (listener , metadata );
278+ listener .onHeaders (new Metadata ());
279+ }
280+ });
281+ assertThat (r ).isNotNull ();
282+ r .run ();
283+ verify (mockRealCall ).cancel (eq ("Failed to read headers" ), eq (boom ));
284+ }
285+
286+ @ Test
287+ public void listenerThrowsInPendingOnReady_cancelsRealCall () {
288+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
289+ callExecutor , fakeClock .getScheduledExecutorService (), null );
290+ final RuntimeException boom = new RuntimeException ("boom" );
291+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
292+ @ Override
293+ public void onReady () {
294+ throw boom ;
295+ }
296+ };
297+ delayedClientCall .start (throwingListener , new Metadata ());
298+ Runnable r = delayedClientCall .setCall (new SimpleForwardingClientCall <String , Integer >(
299+ mockRealCall ) {
300+ @ Override
301+ public void start (Listener <Integer > listener , Metadata metadata ) {
302+ super .start (listener , metadata );
303+ listener .onReady ();
304+ }
305+ });
306+ assertThat (r ).isNotNull ();
307+ r .run ();
308+ verify (mockRealCall ).cancel (eq ("Failed to call onReady." ), eq (boom ));
309+ }
310+
311+ @ Test
312+ public void onCloseExceptionCaughtAndLogged () {
313+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
314+ callExecutor , fakeClock .getScheduledExecutorService (), null );
315+ final RuntimeException boom = new RuntimeException ("boom" );
316+ final AtomicReference <Status > observed = new AtomicReference <>();
317+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
318+ @ Override
319+ public void onClose (Status status , Metadata trailers ) {
320+ observed .set (status );
321+ throw boom ;
322+ }
323+ };
324+ delayedClientCall .start (throwingListener , new Metadata ());
325+ Runnable r = delayedClientCall .setCall (new SimpleForwardingClientCall <String , Integer >(
326+ mockRealCall ) {
327+ @ Override
328+ public void start (Listener <Integer > listener , Metadata metadata ) {
329+ super .start (listener , metadata );
330+ listener .onClose (Status .DATA_LOSS , new Metadata ());
331+ }
332+ });
333+ assertThat (r ).isNotNull ();
334+ r .run (); // Must not propagate `boom`.
335+ assertThat (observed .get ().getCode ()).isEqualTo (Status .Code .DATA_LOSS );
336+ verify (mockRealCall , never ()).cancel (any (), any ());
337+ }
338+
339+ @ Test
340+ public void listenerThrowsInPassThroughOnMessage_cancelsRealCall () {
341+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
342+ callExecutor , fakeClock .getScheduledExecutorService (), null );
343+ final RuntimeException boom = new RuntimeException ("boom" );
344+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
345+ @ Override
346+ public void onMessage (Integer msg ) {
347+ throw boom ;
348+ }
349+ };
350+ delayedClientCall .start (throwingListener , new Metadata ());
351+ Runnable r = delayedClientCall .setCall (mockRealCall );
352+ assertThat (r ).isNotNull ();
353+ r .run (); // drain completes, listener transitions to passThrough
354+ @ SuppressWarnings ("unchecked" )
355+ ArgumentCaptor <Listener <Integer >> listenerCaptor = ArgumentCaptor .forClass (Listener .class );
356+ verify (mockRealCall ).start (listenerCaptor .capture (), any (Metadata .class ));
357+ Listener <Integer > realCallListener = listenerCaptor .getValue ();
358+ realCallListener .onMessage (42 ); // dispatched on passThrough fast path
359+ verify (mockRealCall ).cancel (eq ("Failed to read message." ), eq (boom ));
360+ }
361+
362+ @ Test
363+ public void listenerThrowsInPassThroughOnHeaders_cancelsRealCall () {
364+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
365+ callExecutor , fakeClock .getScheduledExecutorService (), null );
366+ final RuntimeException boom = new RuntimeException ("boom" );
367+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
368+ @ Override
369+ public void onHeaders (Metadata headers ) {
370+ throw boom ;
371+ }
372+ };
373+ delayedClientCall .start (throwingListener , new Metadata ());
374+ Runnable r = delayedClientCall .setCall (mockRealCall );
375+ assertThat (r ).isNotNull ();
376+ r .run ();
377+ @ SuppressWarnings ("unchecked" )
378+ ArgumentCaptor <Listener <Integer >> listenerCaptor = ArgumentCaptor .forClass (Listener .class );
379+ verify (mockRealCall ).start (listenerCaptor .capture (), any (Metadata .class ));
380+ Listener <Integer > realCallListener = listenerCaptor .getValue ();
381+ realCallListener .onHeaders (new Metadata ());
382+ verify (mockRealCall ).cancel (eq ("Failed to read headers" ), eq (boom ));
383+ }
384+
385+ @ Test
386+ public void listenerThrowsInPassThroughOnReady_cancelsRealCall () {
387+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
388+ callExecutor , fakeClock .getScheduledExecutorService (), null );
389+ final RuntimeException boom = new RuntimeException ("boom" );
390+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
391+ @ Override
392+ public void onReady () {
393+ throw boom ;
394+ }
395+ };
396+ delayedClientCall .start (throwingListener , new Metadata ());
397+ Runnable r = delayedClientCall .setCall (mockRealCall );
398+ assertThat (r ).isNotNull ();
399+ r .run ();
400+ @ SuppressWarnings ("unchecked" )
401+ ArgumentCaptor <Listener <Integer >> listenerCaptor = ArgumentCaptor .forClass (Listener .class );
402+ verify (mockRealCall ).start (listenerCaptor .capture (), any (Metadata .class ));
403+ Listener <Integer > realCallListener = listenerCaptor .getValue ();
404+ realCallListener .onReady ();
405+ verify (mockRealCall ).cancel (eq ("Failed to call onReady." ), eq (boom ));
406+ }
407+
408+ @ Test
409+ public void listenerThrowsInPassThrough_subsequentCallbacksSwallowedAndOnCloseOverridden () {
410+ DelayedClientCall <String , Integer > delayedClientCall = new DelayedClientCall <>(
411+ callExecutor , fakeClock .getScheduledExecutorService (), null );
412+ final RuntimeException boom = new RuntimeException ("boom" );
413+ final AtomicReference <Integer > lastMessage = new AtomicReference <>();
414+ final AtomicReference <Status > closeStatus = new AtomicReference <>();
415+ final AtomicReference <Metadata > closeTrailers = new AtomicReference <>();
416+ ClientCall .Listener <Integer > throwingListener = new ClientCall .Listener <Integer >() {
417+ @ Override
418+ public void onMessage (Integer msg ) {
419+ lastMessage .set (msg );
420+ if (msg == 1 ) {
421+ throw boom ;
422+ }
423+ }
424+
425+ @ Override
426+ public void onClose (Status status , Metadata trailers ) {
427+ closeStatus .set (status );
428+ closeTrailers .set (trailers );
429+ }
430+ };
431+ delayedClientCall .start (throwingListener , new Metadata ());
432+ Runnable r = delayedClientCall .setCall (mockRealCall );
433+ assertThat (r ).isNotNull ();
434+ r .run ();
435+ @ SuppressWarnings ("unchecked" )
436+ ArgumentCaptor <Listener <Integer >> listenerCaptor = ArgumentCaptor .forClass (Listener .class );
437+ verify (mockRealCall ).start (listenerCaptor .capture (), any (Metadata .class ));
438+ Listener <Integer > realCallListener = listenerCaptor .getValue ();
439+
440+ realCallListener .onMessage (1 ); // throws -> exceptionStatus captured
441+ assertThat (lastMessage .get ()).isEqualTo (1 );
442+ verify (mockRealCall ).cancel (eq ("Failed to read message." ), eq (boom ));
443+
444+ // Later callbacks are swallowed — the listener must not see message 2.
445+ realCallListener .onMessage (2 );
446+ assertThat (lastMessage .get ()).isEqualTo (1 );
447+
448+ // Transport onClose with OK must be overridden by the captured CANCELLED status.
449+ Metadata serverTrailers = new Metadata ();
450+ serverTrailers .put (Metadata .Key .of ("k" , Metadata .ASCII_STRING_MARSHALLER ), "v" );
451+ realCallListener .onClose (Status .OK , serverTrailers );
452+ assertThat (closeStatus .get ().getCode ()).isEqualTo (Status .Code .CANCELLED );
453+ assertThat (closeStatus .get ().getCause ()).isEqualTo (boom );
454+ // Trailers replaced to avoid mixing sources.
455+ assertThat (closeTrailers .get ()).isNotSameInstanceAs (serverTrailers );
456+ }
457+
232458 private void callMeMaybe (Runnable r ) {
233459 if (r != null ) {
234460 r .run ();
0 commit comments