This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch 9.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/9.0.x by this push:
     new cb7cc5e1c8 Fix BZ 70247 - connection window leak on stream reset
cb7cc5e1c8 is described below

commit cb7cc5e1c8d878046c438e51ac7fc98810206f70
Author: Mark Thomas <[email protected]>
AuthorDate: Thu Oct 1 13:07:37 2026 +0100

    Fix BZ 70247 - connection window leak on stream reset
    
    Based on a patch provided by Guillaume Darmont
---
 java/org/apache/coyote/http2/Stream.java           |  31 ++--
 java/org/apache/coyote/http2/StreamProcessor.java  |   7 +-
 .../apache/coyote/http2/TestStreamProcessor.java   | 171 +++++++++++++++++++++
 webapps/docs/changelog.xml                         |   5 +
 4 files changed, 198 insertions(+), 16 deletions(-)

diff --git a/java/org/apache/coyote/http2/Stream.java 
b/java/org/apache/coyote/http2/Stream.java
index 7706e8d509..a95f90eee1 100644
--- a/java/org/apache/coyote/http2/Stream.java
+++ b/java/org/apache/coyote/http2/Stream.java
@@ -886,21 +886,26 @@ class Stream extends AbstractNonZeroStream implements 
HeaderEmitter {
 
 
     final void close(Http2Exception http2Exception) {
-        if (http2Exception instanceof StreamException) {
+        if (http2Exception instanceof ConnectionException) {
+            handler.closeConnection(http2Exception);
+        } else {
             try {
                 StreamException se = (StreamException) http2Exception;
-                if (log.isTraceEnabled()) {
-                    log.trace(sm.getString("stream.reset.send", 
getConnectionId(), getIdAsString(), se.getError()));
-                }
+                // se may be null when the clean-up is required without 
sending the reset
+                if (se != null) {
+                    if (log.isTraceEnabled()) {
+                        log.trace(sm.getString("stream.reset.send", 
getConnectionId(), getIdAsString(), se.getError()));
+                    }
 
-                // Need to update state atomically with the sending of the RST
-                // frame else other threads currently working with this stream
-                // may see the state change and send a RST frame before the RST
-                // frame triggered by this thread. If that happens the client
-                // may see out of order RST frames which may hard to follow if
-                // the client is unaware the RST frames may be received out of
-                // order.
-                handler.sendStreamReset(state, se);
+                    // Need to update state atomically with the sending of the 
RST
+                    // frame else other threads currently working with this 
stream
+                    // may see the state change and send a RST frame before 
the RST
+                    // frame triggered by this thread. If that happens the 
client
+                    // may see out of order RST frames which may hard to 
follow if
+                    // the client is unaware the RST frames may be received 
out of
+                    // order.
+                    handler.sendStreamReset(state, se);
+                }
 
                 cancelAllocationRequests();
                 inputBuffer.swallowUnread();
@@ -910,8 +915,6 @@ class Stream extends AbstractNonZeroStream implements 
HeaderEmitter {
                                 Http2Error.PROTOCOL_ERROR, ioe);
                 handler.closeConnection(ce);
             }
-        } else {
-            handler.closeConnection(http2Exception);
         }
         replace();
     }
diff --git a/java/org/apache/coyote/http2/StreamProcessor.java 
b/java/org/apache/coyote/http2/StreamProcessor.java
index 1c1a8cb362..9bdc460720 100644
--- a/java/org/apache/coyote/http2/StreamProcessor.java
+++ b/java/org/apache/coyote/http2/StreamProcessor.java
@@ -131,8 +131,11 @@ class StreamProcessor extends AbstractProcessor implements 
NonPipeliningProcesso
                             stream.close(se);
                         } else {
                             if (!stream.isActive()) {
-                                // Close calls replace() so need the same call 
here
-                                stream.replace();
+                                /*
+                                 * Still need to call close to perform the 
necessary clean-up but no need to send reset
+                                 * since the stream is not active so pass null.
+                                 */
+                                stream.close(null);
                             }
                         }
                     }
diff --git a/test/org/apache/coyote/http2/TestStreamProcessor.java 
b/test/org/apache/coyote/http2/TestStreamProcessor.java
index 532096a013..e9bb775641 100644
--- a/test/org/apache/coyote/http2/TestStreamProcessor.java
+++ b/test/org/apache/coyote/http2/TestStreamProcessor.java
@@ -23,6 +23,8 @@ import java.io.PrintWriter;
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 
 import javax.servlet.AsyncContext;
 import javax.servlet.ServletException;
@@ -45,6 +47,10 @@ import org.apache.tomcat.util.http.Method;
 
 public class TestStreamProcessor extends Http2TestBase {
 
+    private static final int INCOMPLETE_READ_BODY_SIZE = 8192;
+
+    private static CountDownLatch incompleteReadLatch = null;
+
     @Test
     public void testAsyncComplete() throws Exception {
         enableHttp2();
@@ -800,4 +806,169 @@ public class TestStreamProcessor extends Http2TestBase {
             resp.getWriter().write("OK");
         }
     }
+
+
+    /*
+     * Connection window leak.
+     * <p>
+     * https://bz.apache.org/bugzilla/show_bug.cgi?id=70247
+     * <p>
+     * The client sends the complete request body. The application does not 
read it.
+     */
+    @Test
+    public void testWindowLeakWithUnreadRequestBody() throws Exception {
+        incompleteReadLatch = null;
+
+        enableHttp2(200, false, 10000, 10000, 2000, 5000, 5000);
+
+        Tomcat tomcat = getTomcatInstance();
+
+        Context ctxt = getProgrammaticRootContext();
+        Tomcat.addServlet(ctxt, "simple", new SimpleServlet());
+        ctxt.addServletMapping("/simple", "simple");
+        Tomcat.addServlet(ctxt, "reject", new RejectServlet());
+        ctxt.addServletMapping("/reject", "reject");
+
+        tomcat.start();
+
+        openClientConnection();
+        doHttpUpgrade();
+        sendClientPreface();
+        validateHttp2InitialResponse();
+
+        byte[] headersFrameHeader = new byte[9];
+        ByteBuffer headersPayload = ByteBuffer.allocate(128);
+        byte[] dataFrameHeader = new byte[9];
+        ByteBuffer dataPayload = 
ByteBuffer.allocate(INCOMPLETE_READ_BODY_SIZE);
+
+        buildPostRequest(headersFrameHeader, headersPayload, false, null, -1, 
"/reject", dataFrameHeader,
+                dataPayload, null, false, 3);
+
+        writeFrame(headersFrameHeader, headersPayload);
+        writeFrame(dataFrameHeader, dataPayload);
+
+        // Response headers
+        parser.readFrame();
+        // Empty response body with end of stream
+        parser.readFrame();
+        // connection window update
+        parser.readFrame();
+
+        Assert.assertEquals("3-HeadersStart\n" + "3-Header-[:status]-[403]\n" 
+ "3-Header-[content-length]-[0]\n" +
+                "3-Header-[date]-[" + DEFAULT_DATE + "]\n" + "3-HeadersEnd\n" 
+ "3-Body-0\n" + "3-EndOfStream\n" +
+                "0-WindowSize-[" + INCOMPLETE_READ_BODY_SIZE + "]\n", 
output.getTrace());
+    }
+
+
+    /*
+     * Connection window leak.
+     * <p>
+     * https://bz.apache.org/bugzilla/show_bug.cgi?id=70247
+     * <p>
+     * The client resets the stream after the response has been written but 
before the container completes the request.
+     * The request body that the application did not read must still be 
returned to the connection window.
+     */
+    @Test
+    public void testResetAfterResponseCommitted() throws Exception {
+        incompleteReadLatch = new CountDownLatch(1);
+
+        enableHttp2(200, false, 10000, 10000, 2000, 5000, 5000);
+
+        Tomcat tomcat = getTomcatInstance();
+
+        Context ctxt = getProgrammaticRootContext();
+        Tomcat.addServlet(ctxt, "simple", new SimpleServlet());
+        ctxt.addServletMapping("/simple", "simple");
+        Tomcat.addServlet(ctxt, "reject", new RejectServlet());
+        ctxt.addServletMapping("/reject", "reject");
+
+        tomcat.start();
+
+        openClientConnection();
+        doHttpUpgrade();
+        sendClientPreface();
+        validateHttp2InitialResponse();
+
+        // No end of stream. The client keeps the stream open and then cancels 
it.
+        byte[] headersFrameHeader = new byte[9];
+        ByteBuffer headersPayload = ByteBuffer.allocate(128);
+        byte[] dataFrameHeader = new byte[9];
+        ByteBuffer dataPayload = 
ByteBuffer.allocate(INCOMPLETE_READ_BODY_SIZE);
+
+        buildPostRequest(headersFrameHeader, headersPayload, false, null, -1, 
"/reject", dataFrameHeader,
+                dataPayload, null, false, 3);
+        // Clear the end of stream flag set by buildPostRequest()
+        dataFrameHeader[4] = 0x00;
+
+        writeFrame(headersFrameHeader, headersPayload);
+        writeFrame(dataFrameHeader, dataPayload);
+
+        // The servlet commits the response and then waits
+        // Response headers
+        parser.readFrame();
+        Assert.assertTrue(output.getTrace(), 
output.getTrace().contains("3-Header-[:status]-[403]\n"));
+        output.clearTrace();
+
+        // Cancel the stream. The ping confirms the connection thread 
processed the reset before the request
+        // processing thread continues.
+        sendRst(3, Http2Error.CANCEL.getCode());
+        sendPing();
+        parser.readFrame();
+        Assert.assertEquals("0-Ping-Ack-[0,0,0,0,0,0,0,0]\n", 
output.getTrace());
+        output.clearTrace();
+
+        // Let the request processing thread complete the request
+        incompleteReadLatch.countDown();
+
+        // The stream is reset, thus only the connection window update remains
+        parser.readFrame();
+        Assert.assertEquals("0-WindowSize-[" + INCOMPLETE_READ_BODY_SIZE + 
"]\n", output.getTrace());
+    }
+
+
+    private static class RejectServlet extends SimpleServlet {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void doPost(HttpServletRequest req, HttpServletResponse 
resp) throws ServletException, IOException {
+            // The request body has to be buffered by the container before the 
request completes
+            waitForRequestBody(req);
+
+            // Reject the request without reading the request body
+            resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
+
+            CountDownLatch latch = incompleteReadLatch;
+            if (latch != null) {
+                // Commit the response and then wait for the client to reset 
the stream
+                resp.flushBuffer();
+                try {
+                    Assert.assertTrue(latch.await(10, TimeUnit.SECONDS));
+                } catch (InterruptedException e) {
+                    throw new IOException(e);
+                }
+            }
+        }
+
+
+        /*
+         * available() returns a positive value once the complete DATA frame 
has been buffered. The parser processes
+         * the end of stream flag of that frame before it makes the payload 
available.
+         */
+        private void waitForRequestBody(HttpServletRequest req) throws 
IOException {
+            long count = 0;
+            while (req.getInputStream().available() == 0) {
+                // Allow 10s (far more than necessary)
+                if (count > 200) {
+                    throw new IOException("Request body did not arrive");
+                }
+                try {
+                    Thread.sleep(50);
+                } catch (InterruptedException e) {
+                    throw new IOException(e);
+                }
+                count++;
+            }
+        }
+    }
 }
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 9817cc0e04..36b8fc525f 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -162,6 +162,11 @@
         Ensure that the non-blocking write buffer is empty before sending an 
AJP
         end response message. (markt)
       </fix>
+      <fix>
+        <bug>70247</bug>: Fix a leak in the HTTP/2 connection flow control
+        window when part of the HTTP request body is received but not read.
+        Based on a patch provided by Guillaume Darmont. (markt)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Jasper">


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to