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

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


The following commit(s) were added to refs/heads/11.0.x by this push:
     new 438f8db104 Review "Fix end-of-input and ReadListener call-back 
contract ..."
438f8db104 is described below

commit 438f8db104a3677a85fe7e939f3e0e9951db0151
Author: Mark Thomas <[email protected]>
AuthorDate: Mon Nov 2 09:36:07 2026 +0000

    Review "Fix end-of-input and ReadListener call-back contract ..."
    
    Fix a regression that would cause the CPU to enter a tight loop
    Also smaller related changes:
    - reduce duplicate code for closing upgrade input/output streams
    - document that upgrade processors are not recycled
    
    Co-authored by: Codex / GPT-6.1-Sol
---
 java/org/apache/coyote/AbstractProtocol.java       |   5 +
 .../http11/upgrade/UpgradeProcessorBase.java       |   6 +
 .../http11/upgrade/UpgradeProcessorExternal.java   |  70 +++++---
 .../http11/upgrade/UpgradeServletInputStream.java  |   5 +
 .../apache/tomcat/util/net/AbstractEndpoint.java   |   4 +
 .../apache/coyote/http11/upgrade/TestUpgrade.java  | 184 +++++++++++++++++++++
 webapps/docs/changelog.xml                         |   7 +
 7 files changed, 257 insertions(+), 24 deletions(-)

diff --git a/java/org/apache/coyote/AbstractProtocol.java 
b/java/org/apache/coyote/AbstractProtocol.java
index fdba5cf0b4..c3793728bc 100644
--- a/java/org/apache/coyote/AbstractProtocol.java
+++ b/java/org/apache/coyote/AbstractProtocol.java
@@ -1408,6 +1408,11 @@ public abstract class AbstractProtocol<S> implements 
ProtocolHandler, MBeanRegis
                         longPoll(wrapper, processor);
                         getProtocol().addWaitingProcessor(processor);
                     }
+                } else if (state == SocketState.UPGRADED_HALF_CLOSED) {
+                    // Don't add sockets back to the poller.
+                    // The handler will register for write as required
+                    // Add to waiting processors so the processor is visible 
if the protocol is stopped
+                    getProtocol().addWaitingProcessor(processor);
                 } else if (state == SocketState.ASYNC_IO) {
                     // Don't add sockets back to the poller.
                     // The handler will initiate all further I/O
diff --git a/java/org/apache/coyote/http11/upgrade/UpgradeProcessorBase.java 
b/java/org/apache/coyote/http11/upgrade/UpgradeProcessorBase.java
index 62e0ae1a99..432b4240e8 100644
--- a/java/org/apache/coyote/http11/upgrade/UpgradeProcessorBase.java
+++ b/java/org/apache/coyote/http11/upgrade/UpgradeProcessorBase.java
@@ -66,6 +66,12 @@ public abstract class UpgradeProcessorBase extends 
AbstractProcessorLight implem
     }
 
 
+    /**
+     * {@inheritDoc}
+     * <p>
+     * Upgrade processors are not recycled and re-used. This method is only 
used to trigger any necessary clean-up once
+     * the upgrade processor is no longer required.
+     */
     @Override
     public void recycle() {
         // NO-OP. Sub-classes override this to release resources when the
diff --git 
a/java/org/apache/coyote/http11/upgrade/UpgradeProcessorExternal.java 
b/java/org/apache/coyote/http11/upgrade/UpgradeProcessorExternal.java
index 9b42804c89..5cc0426f54 100644
--- a/java/org/apache/coyote/http11/upgrade/UpgradeProcessorExternal.java
+++ b/java/org/apache/coyote/http11/upgrade/UpgradeProcessorExternal.java
@@ -17,6 +17,7 @@
 package org.apache.coyote.http11.upgrade;
 
 import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import jakarta.servlet.ServletInputStream;
 import jakarta.servlet.ServletOutputStream;
@@ -24,6 +25,7 @@ import jakarta.servlet.ServletOutputStream;
 import org.apache.coyote.UpgradeToken;
 import org.apache.juli.logging.Log;
 import org.apache.juli.logging.LogFactory;
+import org.apache.tomcat.util.ExceptionUtils;
 import org.apache.tomcat.util.net.AbstractEndpoint.Handler.SocketState;
 import org.apache.tomcat.util.net.SSLSupport;
 import org.apache.tomcat.util.net.SocketEvent;
@@ -58,11 +60,13 @@ public class UpgradeProcessorExternal extends 
UpgradeProcessorBase {
      */
     private final SocketWrapperBase<?> wrapper;
 
+    private AtomicBoolean stopCalled = new AtomicBoolean();
+
     /**
      * Constructs a new UpgradeProcessorExternal.
      *
-     * @param wrapper the socket wrapper
-     * @param upgradeToken the upgrade token
+     * @param wrapper          the socket wrapper
+     * @param upgradeToken     the upgrade token
      * @param upgradeGroupInfo the group info for statistics
      */
     public UpgradeProcessorExternal(SocketWrapperBase<?> wrapper, UpgradeToken 
upgradeToken,
@@ -105,10 +109,16 @@ public class UpgradeProcessorExternal extends 
UpgradeProcessorBase {
 
     @Override
     public void close() throws Exception {
-        upgradeServletInputStream.close();
-        upgradeServletOutputStream.close();
+        closeStreamsQuietly();
         // Triggers update of stats from UpgradeInfo to UpgradeGroupInfo
         upgradeInfo.setGroupInfo(null);
+        if (stopCalled.compareAndSet(false, true)) {
+            /*
+             * Read polling may have stopped at EOF. Ensure an application 
thread closing the connection still
+             * triggers socket closure and handler cleanup on a container 
thread.
+             */
+            wrapper.processSocket(SocketEvent.STOP, true);
+        }
     }
 
 
@@ -137,16 +147,7 @@ public class UpgradeProcessorExternal extends 
UpgradeProcessorBase {
             if (log.isTraceEnabled()) {
                 log.trace(sm.getString("upgradeProcessor.stop"));
             }
-            try {
-                upgradeServletInputStream.close();
-            } catch (IOException ioe) {
-                log.debug(sm.getString("upgradeProcessor.isCloseFail", ioe));
-            }
-            try {
-                upgradeServletOutputStream.close();
-            } catch (IOException ioe) {
-                log.debug(sm.getString("upgradeProcessor.osCloseFail", ioe));
-            }
+            closeStreamsQuietly();
             return SocketState.CLOSED;
         } else if (status == SocketEvent.ERROR) {
             // Give the listeners (if any) the chance to handle the error 
before
@@ -157,16 +158,7 @@ public class UpgradeProcessorExternal extends 
UpgradeProcessorBase {
             }
             upgradeServletInputStream.onError(error);
             upgradeServletOutputStream.onError(error);
-            try {
-                upgradeServletInputStream.close();
-            } catch (IOException ioe) {
-                log.debug(sm.getString("upgradeProcessor.isCloseFail", ioe));
-            }
-            try {
-                upgradeServletOutputStream.close();
-            } catch (IOException ioe) {
-                log.debug(sm.getString("upgradeProcessor.osCloseFail", ioe));
-            }
+            closeStreamsQuietly();
             return SocketState.CLOSED;
         } else {
             // Unexpected state
@@ -183,10 +175,40 @@ public class UpgradeProcessorExternal extends 
UpgradeProcessorBase {
             }
             return SocketState.CLOSED;
         }
+        if (upgradeServletInputStream.isEof()) {
+            /*
+             * Do not re-register read interest after EOF, but allow the 
output stream to register write interest while
+             * the application finishes its response.
+             */
+            return SocketState.UPGRADED_HALF_CLOSED;
+        }
         return SocketState.UPGRADED;
     }
 
 
+    /*
+     * Close the input and output streams if not already closed, logging any 
exceptions at debug level.
+     */
+    private void closeStreamsQuietly() {
+        try {
+            if (!upgradeServletInputStream.isClosed()) {
+                upgradeServletInputStream.close();
+            }
+        } catch (Throwable t) {
+            ExceptionUtils.handleThrowable(t);
+            log.debug(sm.getString("upgradeProcessor.isCloseFail", t));
+        }
+        try {
+            if (!upgradeServletOutputStream.isClosed()) {
+                upgradeServletOutputStream.close();
+            }
+        } catch (Throwable t) {
+            ExceptionUtils.handleThrowable(t);
+            log.debug(sm.getString("upgradeProcessor.osCloseFail", t));
+        }
+    }
+
+
     // ----------------------------------------- Unimplemented Processor 
methods
 
     @Override
diff --git 
a/java/org/apache/coyote/http11/upgrade/UpgradeServletInputStream.java 
b/java/org/apache/coyote/http11/upgrade/UpgradeServletInputStream.java
index 7ae52006c6..90505e6b2b 100644
--- a/java/org/apache/coyote/http11/upgrade/UpgradeServletInputStream.java
+++ b/java/org/apache/coyote/http11/upgrade/UpgradeServletInputStream.java
@@ -324,4 +324,9 @@ public class UpgradeServletInputStream extends 
ServletInputStream {
     final boolean isClosed() {
         return closed;
     }
+
+
+    final boolean isEof() {
+        return eof;
+    }
 }
diff --git a/java/org/apache/tomcat/util/net/AbstractEndpoint.java 
b/java/org/apache/tomcat/util/net/AbstractEndpoint.java
index 05c368ba04..18037a6fef 100644
--- a/java/org/apache/tomcat/util/net/AbstractEndpoint.java
+++ b/java/org/apache/tomcat/util/net/AbstractEndpoint.java
@@ -130,6 +130,10 @@ public abstract class AbstractEndpoint<S, U> {
              * The connection has been upgraded.
              */
             UPGRADED,
+            /**
+             * The connection has been closed for reads from the client but 
writes to the client may continue.
+             */
+            UPGRADED_HALF_CLOSED,
             /**
              * Asynchronous I/O is in progress.
              */
diff --git a/test/org/apache/coyote/http11/upgrade/TestUpgrade.java 
b/test/org/apache/coyote/http11/upgrade/TestUpgrade.java
index f6bb1b7590..5188529eed 100644
--- a/test/org/apache/coyote/http11/upgrade/TestUpgrade.java
+++ b/test/org/apache/coyote/http11/upgrade/TestUpgrade.java
@@ -30,6 +30,9 @@ import java.net.Socket;
 import java.net.SocketException;
 import java.nio.charset.StandardCharsets;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BooleanSupplier;
 
 import javax.net.SocketFactory;
@@ -50,14 +53,18 @@ import org.junit.Test;
 
 import static org.apache.catalina.startup.SimpleHttpClient.CRLF;
 import org.apache.catalina.Context;
+import org.apache.catalina.connector.Connector;
 import org.apache.catalina.startup.Tomcat;
 import org.apache.catalina.startup.TomcatBaseTest;
 import org.apache.coyote.UpgradeToken;
 import org.apache.coyote.http11.AbstractHttp11Protocol;
+import org.apache.coyote.http11.Http11NioProtocol;
 import org.apache.juli.logging.Log;
 import org.apache.juli.logging.LogFactory;
 import org.apache.tomcat.ContextBind;
 import org.apache.tomcat.util.net.AbstractEndpoint.Handler.SocketState;
+import org.apache.tomcat.util.net.NioChannel;
+import org.apache.tomcat.util.net.NioEndpoint;
 import org.apache.tomcat.util.net.SSLSupport;
 import org.apache.tomcat.util.net.SocketEvent;
 import org.apache.tomcat.util.net.SocketWrapperBase;
@@ -153,6 +160,105 @@ public class TestUpgrade extends TomcatBaseTest {
         uc.shutdownInput();
     }
 
+    @Test
+    public void testDelayedResponseAfterEofNonBlockingReadNonBlockingWrite() 
throws Exception {
+        doTestDelayedResponseAfterEof(false, false);
+    }
+
+    @Test
+    public void testDelayedResponseAfterEofNonBlockingReadBlockingWrite() 
throws Exception {
+        doTestDelayedResponseAfterEof(false, true);
+    }
+
+    @Test
+    public void testDelayedResponseAfterEofBlockingReadNonBlockingWrite() 
throws Exception {
+        doTestDelayedResponseAfterEof(true, false);
+    }
+
+    @Test
+    public void testDelayedResponseAfterEofBlockingReadBlockingWrite() throws 
Exception {
+        doTestDelayedResponseAfterEof(true, true);
+    }
+
+    private void doTestDelayedResponseAfterEof(boolean blockingRead, boolean 
blockingWrite) throws Exception {
+        Tomcat tomcat = getTomcatInstance();
+        CountingEndpoint endpoint = new CountingEndpoint();
+        Connector connector = new Connector(new Http11NioProtocol(endpoint));
+        connector.setPort(0);
+        connector.setThrowOnFailure(true);
+        Assert.assertTrue(connector.setProperty("address", "localhost"));
+        tomcat.getService().removeConnector(tomcat.getConnector());
+        tomcat.setConnector(connector);
+        Context context = getProgrammaticRootContext();
+        AtomicReference<EofHandler> handlerRef = new AtomicReference<>();
+        Tomcat.addServlet(context, "upgrade", new HttpServlet() {
+            private static final long serialVersionUID = 1L;
+
+            @Override
+            protected void doGet(HttpServletRequest request, 
HttpServletResponse response)
+                    throws ServletException, IOException {
+                response.setHeader("Upgrade", "test");
+                EofHandler handler = request.upgrade(EofHandler.class);
+                handler.blockingRead = blockingRead;
+                handlerRef.set(handler);
+            }
+        });
+        context.addServletMapping("/", "upgrade");
+        tomcat.start();
+
+        try (Socket socket = new Socket("localhost", getPort())) {
+            socket.setSoTimeout(5000);
+            socket.getOutputStream().write(
+                    "GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: 
test\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
+            BufferedReader reader = new BufferedReader(new 
InputStreamReader(socket.getInputStream(),
+                    StandardCharsets.US_ASCII));
+            Assert.assertTrue(reader.readLine().contains("101"));
+            while (!reader.readLine().isEmpty()) {
+                // Skip response headers
+            }
+            socket.shutdownOutput();
+
+            EofHandler handler = handlerRef.get();
+            try {
+                Assert.assertTrue(handler.inputFinished.await(5, 
TimeUnit.SECONDS));
+                Assert.assertNull(handler.error);
+                endpoint.inputFinished = true;
+                /*
+                 * A read event may already be queued, but EOF must not cause 
repeated dispatches while output is open.
+                 *
+                 * There is a trade-off here. On slow systems a repeated read 
may take a while to arrive but the longer
+                 * the wait, the more delay it adds to the tests. 300ms is a 
reasonable delay and while a heavily loaded
+                 * test machine may miss the failure, the tests are run often 
enough that that is acceptable.
+                 */
+                Assert.assertFalse("Read polling continued after EOF",
+                        endpoint.repeatedReads.await(300, 
TimeUnit.MILLISECONDS));
+
+                if (blockingWrite) {
+                    handler.writeResponse();
+                } else {
+                    handler.connection.getOutputStream().setWriteListener(new 
WriteListener() {
+                        @Override
+                        public void onWritePossible() throws IOException {
+                            handler.writeResponse();
+                        }
+
+                        @Override
+                        public void onError(Throwable t) {
+                            handler.error = t;
+                        }
+                    });
+                }
+                Assert.assertEquals('O', reader.read());
+                // Closing from an application thread must still close the 
socket when read polling has stopped.
+                Assert.assertEquals(-1, reader.read());
+                Assert.assertTrue(handler.destroyed.await(5, 
TimeUnit.SECONDS));
+                Assert.assertNull(handler.error);
+            } finally {
+                handler.connection.close();
+            }
+        }
+    }
+
     @Test
     public void testNoCallbacksAfterError() throws Exception {
         SocketWrapperBase<?> socketWrapper = 
EasyMock.createMock(SocketWrapperBase.class);
@@ -912,6 +1018,84 @@ public class TestUpgrade extends TomcatBaseTest {
     }
 
 
+    public static class CountingEndpoint extends NioEndpoint {
+        private volatile boolean inputFinished;
+        private final CountDownLatch repeatedReads = new CountDownLatch(2);
+
+        @Override
+        public boolean processSocket(SocketWrapperBase<NioChannel> socket, 
SocketEvent event, boolean dispatch) {
+            if (inputFinished && event == SocketEvent.OPEN_READ) {
+                repeatedReads.countDown();
+            }
+            return super.processSocket(socket, event, dispatch);
+        }
+    }
+
+    public static class EofHandler implements HttpUpgradeHandler, ReadListener 
{
+        private boolean blockingRead;
+        private WebConnection connection;
+        private ServletInputStream input;
+        private final CountDownLatch inputFinished = new CountDownLatch(1);
+        private final CountDownLatch destroyed = new CountDownLatch(1);
+        private volatile Throwable error;
+
+        @Override
+        public void init(WebConnection connection) {
+            this.connection = connection;
+            try {
+                input = connection.getInputStream();
+                if (blockingRead) {
+                    byte[] buffer = new byte[1024];
+                    while (input.read(buffer) != -1) {
+                        // Discard data until EOF
+                    }
+                    inputFinished.countDown();
+                } else {
+                    input.setReadListener(this);
+                }
+            } catch (IOException e) {
+                onError(e);
+            }
+        }
+
+        @Override
+        public void onDataAvailable() throws IOException {
+            byte[] buffer = new byte[1024];
+            while (input.isReady() && input.read(buffer) != -1) {
+                // Discard data until EOF
+            }
+        }
+
+        @Override
+        public void onAllDataRead() {
+            // Leave both streams open until the application is ready to send 
a response.
+            inputFinished.countDown();
+        }
+
+        @Override
+        public void onError(Throwable t) {
+            error = t;
+            inputFinished.countDown();
+        }
+
+        private void writeResponse() throws IOException {
+            ServletOutputStream output = connection.getOutputStream();
+            output.write('O');
+            output.flush();
+            try {
+                connection.close();
+            } catch (Exception e) {
+                throw new IOException(e);
+            }
+        }
+
+        @Override
+        public void destroy() {
+            destroyed.countDown();
+        }
+    }
+
+
     private static class NoOpReadListener implements ReadListener {
 
         @Override
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 7a22446995..1f8b4f3de3 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -480,6 +480,13 @@
         Detect and disconnect a mis-behaving HTTP/2 client that sends large
         numbers of unexpected settings acknowledgement frames. (markt)
       </fix>
+      <fix>
+        Fix excessive CPU usage after a client half-closes an upgraded
+        connection while the application keeps the output stream open to finish
+        its response. Stop polling for input at end of stream and ensure that a
+        subsequent <code>WebConnection.close()</code> closes the connection.
+        (markt)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Jasper">


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

Reply via email to