From aec35a56e0d60119fc894302b9b2b61df4fbe4af Mon Sep 17 00:00:00 2001 From: Thimios Date: Thu, 16 Jul 2026 19:24:03 +0300 Subject: [PATCH] [fix] avoid infinite loop in Response#isClientAbortException, fixes #449. --- .../java/org/jruby/rack/ext/Response.java | 14 ++++-- src/spec/ruby/jruby/rack/response_spec.rb | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/jruby/rack/ext/Response.java b/src/main/java/org/jruby/rack/ext/Response.java index 8e7d0670a..0a312d4c5 100644 --- a/src/main/java/org/jruby/rack/ext/Response.java +++ b/src/main/java/org/jruby/rack/ext/Response.java @@ -615,15 +615,23 @@ private boolean handledAsClientAbort(final Exception ioe) { return false; } + /** + * How many nested causes {@link #isClientAbortException(Exception)} inspects. + * A cause chain is allowed to be cyclic, thus the traversal needs a bound. + */ + private static final int MAX_CAUSE_DEPTH = 20; + // ioe.inspect =~ /(ClientAbortException|EofException|broken pipe)/i protected boolean isClientAbortException(final Exception ioe) { String error = ioe.toString(); if ( error.contains("ClientAbortException") ) return true; if ( error.contains("EofException") ) return true; - while ( true ) { + Throwable cause = ioe; + for ( int depth = 0; depth < MAX_CAUSE_DEPTH; depth++ ) { if ( error.toLowerCase().contains("broken pipe") ) return true; - if ( ioe.getCause() == null ) break; - error = ioe.getCause().getMessage(); + cause = cause.getCause(); + if ( cause == null ) break; + error = cause.getMessage(); if ( error == null ) break; } return false; diff --git a/src/spec/ruby/jruby/rack/response_spec.rb b/src/spec/ruby/jruby/rack/response_spec.rb index a1dd9b5a6..95f745d47 100644 --- a/src/spec/ruby/jruby/rack/response_spec.rb +++ b/src/spec/ruby/jruby/rack/response_spec.rb @@ -519,6 +519,49 @@ def flush end end + it "raises exceptions with a nested cause that do not look like abort exceptions" do + servlet_response = org.jruby.rack.mock.fail.FailingHttpServletResponse.new + # e.g. Jetty failing a stalled write: IOException caused by a TimeoutException + servlet_response.setFailure java.io.IOException.new( + java.util.concurrent.TimeoutException.new('Idle timeout expired: 30000/30000 ms') + ) + begin + with_swallow_client_abort do + response.write_body new_response_environment(servlet_response) + end + fail 'IO exception NOT raised!' + rescue java.io.IOException => e + expect(e.to_s).to match(/idle timeout expired/i) + end + end + + it "swallows client abort exceptions nested deeper in the cause chain" do + servlet_response = org.jruby.rack.mock.fail.FailingHttpServletResponse.new + servlet_response.setFailure java.io.IOException.new('write failed', + java.io.IOException.new('connection problem', java.io.IOException.new('Broken pipe')) + ) + with_swallow_client_abort do + response.write_body new_response_environment(servlet_response) + end + end + + it "raises (and does not hang) on a cyclic cause chain" do + failure = java.io.IOException.new 'write failed' + cause = java.io.IOException.new 'nested failure' + failure.initCause cause + cause.initCause failure + servlet_response = org.jruby.rack.mock.fail.FailingHttpServletResponse.new + servlet_response.setFailure failure + begin + with_swallow_client_abort do + response.write_body new_response_environment(servlet_response) + end + fail 'IO exception NOT raised!' + rescue java.io.IOException => e + expect(e.to_s).to match(/write failed/i) + end + end + private def with_dechunk(dechunk = true)