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

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


The following commit(s) were added to refs/heads/10.1.x by this push:
     new 1f0ff30cf2 Review "Fix submitted task count drift in 
ThreadPoolExecutor"
1f0ff30cf2 is described below

commit 1f0ff30cf2ef1a759c4811ee967507eba66b18d5
Author: Mark Thomas <[email protected]>
AuthorDate: Wed Nov 4 17:08:00 2026 +0000

    Review "Fix submitted task count drift in ThreadPoolExecutor"
    
    Fix a few more instances of task count drift
    
    Co-authored by: Codex / GPT-6.1-Sol
---
 .../tomcat/util/threads/ThreadPoolExecutor.java    | 109 +++++++----
 .../util/threads/TestThreadPoolExecutor.java       | 216 +++++++++++++++++++--
 2 files changed, 262 insertions(+), 63 deletions(-)

diff --git a/java/org/apache/tomcat/util/threads/ThreadPoolExecutor.java 
b/java/org/apache/tomcat/util/threads/ThreadPoolExecutor.java
index 2779f8e714..ce5076a77f 100644
--- a/java/org/apache/tomcat/util/threads/ThreadPoolExecutor.java
+++ b/java/org/apache/tomcat/util/threads/ThreadPoolExecutor.java
@@ -25,7 +25,6 @@
 package org.apache.tomcat.util.threads;
 
 import java.util.ArrayList;
-import java.util.ConcurrentModificationException;
 import java.util.HashSet;
 import java.util.List;
 import java.util.concurrent.AbstractExecutorService;
@@ -365,6 +364,7 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
      * equal to {@link #getActiveCount()}.
      */
     private final AtomicInteger submittedCount = new AtomicInteger(0);
+    private final ThreadLocal<RejectedTask> rejectedTask = new ThreadLocal<>();
     private final AtomicLong lastContextStoppedTime = new AtomicLong(0L);
 
     /**
@@ -687,20 +687,47 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
      * ScheduledThreadPoolExecutor.
      */
     final void reject(Runnable command) {
+        RejectedTask previous = rejectedTask.get();
+        RejectedTask current = new RejectedTask(command);
+        rejectedTask.set(current);
         try {
-            handler.rejectedExecution(command, this);
-        } catch (RejectedExecutionException rx) {
-            // The caller in execute() decrements the submitted count, or, 
when a retryable queue is used, re-queues
-            // the task which will then be counted by afterExecute() once it 
completes.
-            throw rx;
-        } catch (RuntimeException | Error e) {
-            // The task will not be executed by a worker, so afterExecute() 
will not decrement the submitted count.
-            submittedCount.decrementAndGet();
-            throw e;
+            try {
+                handler.rejectedExecution(command, this);
+            } catch (RejectedExecutionException rx) {
+                // execute() releases the count, or re-queues the task for a 
retryable queue.
+                throw rx;
+            } catch (RuntimeException | Error e) {
+                submittedCount.decrementAndGet();
+                throw e;
+            }
+            // A custom handler may enqueue the task directly. Only release 
its count if a policy reports that it
+            // was discarded, run directly or resubmitted through execute() 
with a separate count.
+            if (current.handled) {
+                submittedCount.decrementAndGet();
+            }
+        } finally {
+            if (previous == null) {
+                rejectedTask.remove();
+            } else {
+                rejectedTask.set(previous);
+            }
+        }
+    }
+
+    private void markRejectedTaskHandled(Runnable command) {
+        RejectedTask current = rejectedTask.get();
+        if (current != null && current.command == command) {
+            current.handled = true;
+        }
+    }
+
+    private static class RejectedTask {
+        private final Runnable command;
+        private boolean handled;
+
+        RejectedTask(Runnable command) {
+            this.command = command;
         }
-        // The handler completed without throwing. The task was discarded or 
run directly by the handler. It will not
-        // pass through afterExecute(), so decrement the submitted count here.
-        submittedCount.decrementAndGet();
     }
 
     /**
@@ -1209,6 +1236,7 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
             // adjustment for this task, since the reject() call below and the
             // catch block in execute() take care of that.
             if (!isRunning(recheck) && workQueue.remove(command)) {
+                tryTerminate(); // In case SHUTDOWN and now empty
                 reject(command);
             } else if (workerCountOf(recheck) == 0) {
                 addWorker(null, false);
@@ -1641,32 +1669,14 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
      */
     public void purge() {
         final BlockingQueue<Runnable> q = workQueue;
-        // Count the cancelled tasks that are removed. These will never be
-        // executed, so afterExecute() will not decrement the submitted count.
-        final AtomicInteger purged = new AtomicInteger(0);
-        try {
-            q.removeIf(r -> {
-                if (r instanceof Future<?> && ((Future<?>) r).isCancelled()) {
-                    purged.incrementAndGet();
-                    return true;
-                }
-                return false;
-            });
-        } catch (ConcurrentModificationException fallThrough) {
-            // Take slow path if we encounter interference during traversal.
-            // Make copy for traversal and call remove for cancelled entries.
-            // The slow path is more likely to be O(N*N). Any entries removed
-            // before the interference occurred are already accounted for in
-            // the purged count and will not appear in the copy taken below.
-            for (Object r : q.toArray()) {
-                if (r instanceof Future<?> && ((Future<?>) r).isCancelled() && 
q.remove(r)) {
-                    purged.incrementAndGet();
-                }
+        // A worker or another purge() may remove a task after it is selected. 
Only release its count if this call
+        // actually removes it. A removeIf() predicate may run even when the 
task is not subsequently removed.
+        for (Object r : q.toArray()) {
+            if (r instanceof Future<?> && ((Future<?>) r).isCancelled() && 
q.remove(r)) {
+                submittedCount.decrementAndGet();
             }
         }
 
-        submittedCount.addAndGet(-purged.get());
-
         tryTerminate(); // In case SHUTDOWN and now empty
     }
 
@@ -1981,8 +1991,12 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
          */
         @Override
         public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
-            if (!e.isShutdown()) {
-                r.run();
+            try {
+                if (!e.isShutdown()) {
+                    r.run();
+                }
+            } finally {
+                e.markRejectedTaskHandled(r);
             }
         }
     }
@@ -2030,6 +2044,7 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
          */
         @Override
         public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
+            e.markRejectedTaskHandled(r);
         }
     }
 
@@ -2069,9 +2084,15 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
          */
         @Override
         public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
-            if (!e.isShutdown()) {
-                e.getQueue().poll();
-                e.execute(r);
+            try {
+                if (!e.isShutdown()) {
+                    if (e.getQueue().poll() != null) {
+                        e.submittedCount.decrementAndGet();
+                    }
+                    e.execute(r);
+                }
+            } finally {
+                e.markRejectedTaskHandled(r);
             }
         }
     }
@@ -2096,6 +2117,10 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
          * <p>
          * In the absence of other alternatives, the method may throw an 
unchecked {@link RejectedExecutionException},
          * which will be propagated to the caller of {@code execute}.
+         * <p>
+         * Handlers that discard tasks or run them directly should delegate to 
{@link DiscardPolicy} or
+         * {@link CallerRunsPolicy}, respectively, so the submitted task count 
is adjusted. Handlers that enqueue tasks
+         * directly leave their counts to be released when the tasks complete.
          *
          * @param r        the runnable task requested to be executed
          * @param executor the executor attempting to execute this task
@@ -2104,4 +2129,4 @@ public class ThreadPoolExecutor extends 
AbstractExecutorService {
          */
         void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
     }
-}
\ No newline at end of file
+}
diff --git a/test/org/apache/tomcat/util/threads/TestThreadPoolExecutor.java 
b/test/org/apache/tomcat/util/threads/TestThreadPoolExecutor.java
index 913ba79824..657a6b1be7 100644
--- a/test/org/apache/tomcat/util/threads/TestThreadPoolExecutor.java
+++ b/test/org/apache/tomcat/util/threads/TestThreadPoolExecutor.java
@@ -20,6 +20,9 @@ import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -83,36 +86,121 @@ public class TestThreadPoolExecutor {
     }
 
     /*
-     * A rejected execution handler that does not throw discards the task or 
runs it in the calling thread. Either way
+     * A built-in rejection policy that does not throw discards the task or 
runs it in the calling thread. Either way
      * the task never reaches afterExecute(), so the submitted count must be 
decremented when the handler returns.
      */
     @Test
     public void testNonThrowingRejectionHandlerKeepsSubmittedCount() throws 
Exception {
-        // Test both handlers that do not throw.
+        // Test both policies, including inherited and delegated handling 
methods.
         for (ThreadPoolExecutor.RejectedExecutionHandler handler : new 
ThreadPoolExecutor.RejectedExecutionHandler[] {
-                new ThreadPoolExecutor.DiscardPolicy(), new 
ThreadPoolExecutor.CallerRunsPolicy() }) {
-            CountDownLatch release = new CountDownLatch(1);
+                new ThreadPoolExecutor.DiscardPolicy(), new 
ThreadPoolExecutor.CallerRunsPolicy(),
+                new ThreadPoolExecutor.DiscardPolicy() {}, new 
ThreadPoolExecutor.CallerRunsPolicy() {} }) {
             boolean expectRunInCaller = handler instanceof 
ThreadPoolExecutor.CallerRunsPolicy;
-            ThreadPoolExecutor pool = createPool(handler);
-            try {
-                CountDownLatch started = new CountDownLatch(1);
-                pool.execute(blockingTask("running", started, release));
-                awaitStarted(started);
-                pool.execute(blockingTask("queued", release));
-                AtomicBoolean ran = new AtomicBoolean();
-                pool.execute(() -> ran.set(true)); // rejected: discarded or 
run by this thread
-                // Only the running and the queued task are still outstanding.
-                Assert.assertEquals(handler.getClass().getName(), 2, 
pool.getSubmittedCount());
-                Assert.assertEquals(handler.getClass().getName(), 
Boolean.valueOf(expectRunInCaller),
-                        Boolean.valueOf(ran.get()));
-                release.countDown();
-                awaitSubmittedCount(pool, 0);
-            } finally {
-                pool.shutdownNow();
-            }
+            doTestNonThrowingRejectionHandler(handler, expectRunInCaller);
+            doTestNonThrowingRejectionHandler((task, executor) -> 
handler.rejectedExecution(task, executor),
+                    expectRunInCaller);
+        }
+    }
+
+
+    private void 
doTestNonThrowingRejectionHandler(ThreadPoolExecutor.RejectedExecutionHandler 
handler,
+            boolean expectRunInCaller) throws Exception {
+        CountDownLatch release = new CountDownLatch(1);
+        ThreadPoolExecutor pool = createPool(handler);
+        try {
+            CountDownLatch started = new CountDownLatch(1);
+            pool.execute(blockingTask("running", started, release));
+            awaitStarted(started);
+            pool.execute(blockingTask("queued", release));
+            AtomicBoolean ran = new AtomicBoolean();
+            pool.execute(() -> ran.set(true)); // rejected: discarded or run 
by this thread
+            // Only the running and the queued task are still outstanding.
+            Assert.assertEquals(handler.getClass().getName(), 2, 
pool.getSubmittedCount());
+            Assert.assertEquals(handler.getClass().getName(), 
Boolean.valueOf(expectRunInCaller),
+                    Boolean.valueOf(ran.get()));
+            release.countDown();
+            awaitSubmittedCount(pool, 0);
+        } finally {
+            pool.shutdownNow();
+            Assert.assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
+        }
+    }
+
+
+    /*
+     * A rejection handler may enqueue the task instead of discarding it or 
running it itself. Even a subclass of a
+     * built-in policy may do this, so returning from the handler must not 
release the queued task's count.
+     */
+    @Test
+    public void testQueuingRejectionHandlerKeepsSubmittedCount() throws 
Exception {
+        CountDownLatch firstStarted = new CountDownLatch(1);
+        CountDownLatch releaseFirst = new CountDownLatch(1);
+        CountDownLatch secondStarted = new CountDownLatch(1);
+        CountDownLatch releaseSecond = new CountDownLatch(1);
+        CountDownLatch thirdCompleted = new CountDownLatch(1);
+        TaskQueue queue = new TaskQueue(1);
+        ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, 
TimeUnit.SECONDS, queue,
+                Executors.defaultThreadFactory(), new 
ThreadPoolExecutor.CallerRunsPolicy() {
+                    @Override
+                    public void rejectedExecution(Runnable task, 
ThreadPoolExecutor executor) {
+                        // Let the worker take the queued task so the handler 
can enqueue the rejected task.
+                        releaseFirst.countDown();
+                        try {
+                            awaitStarted(secondStarted);
+                            executor.getQueue().put(task);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                            throw new RejectedExecutionException(e);
+                        }
+                    }
+                });
+        queue.setParent(pool);
+        try {
+            pool.execute(blockingTask("first", firstStarted, releaseFirst));
+            awaitStarted(firstStarted);
+            pool.execute(blockingTask("second", secondStarted, releaseSecond));
+            pool.execute(thirdCompleted::countDown);
+
+            Assert.assertEquals("One running task and one task queued by the 
handler", 2, pool.getSubmittedCount());
+            Assert.assertEquals(1, queue.size());
+            releaseSecond.countDown();
+            Assert.assertTrue(thirdCompleted.await(5, TimeUnit.SECONDS));
+            awaitSubmittedCount(pool, 0);
+        } finally {
+            releaseFirst.countDown();
+            releaseSecond.countDown();
+            pool.shutdownNow();
+            Assert.assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
+        }
+    }
+
+
+    @Test
+    public void testDiscardOldestPolicyKeepsSubmittedCount() throws Exception {
+        CountDownLatch release = new CountDownLatch(1);
+        CountDownLatch started = new CountDownLatch(1);
+        CountDownLatch completed = new CountDownLatch(1);
+        AtomicBoolean discardedTaskRan = new AtomicBoolean();
+        ThreadPoolExecutor pool = createPool(new 
ThreadPoolExecutor.DiscardOldestPolicy());
+        try {
+            pool.execute(blockingTask("running", started, release));
+            awaitStarted(started);
+            pool.execute(() -> discardedTaskRan.set(true));
+            pool.execute(completed::countDown);
+            // The original queued task was discarded. Only the running task 
and its replacement remain.
+            Assert.assertEquals(2, pool.getSubmittedCount());
+            release.countDown();
+            Assert.assertTrue(completed.await(5, TimeUnit.SECONDS));
+            awaitSubmittedCount(pool, 0);
+            Assert.assertFalse(discardedTaskRan.get());
+        } finally {
+            release.countDown();
+            pool.shutdownNow();
+            Assert.assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
         }
     }
 
+
     /*
      * A null command is rejected with a NullPointerException before it can be 
counted.
      */
@@ -181,6 +269,55 @@ public class TestThreadPoolExecutor {
         }
     }
 
+
+    /*
+     * A worker can dequeue a cancelled future after purge() has selected it 
but before purge() removes it. In that
+     * case afterExecute() accounts for the future and purge() must not 
decrement the count again.
+     */
+    @Test
+    public void testPurgeRacingWithWorkerKeepsSubmittedCount() throws 
Exception {
+        CountDownLatch firstStarted = new CountDownLatch(1);
+        CountDownLatch releaseFirst = new CountDownLatch(1);
+        CountDownLatch secondStarted = new CountDownLatch(1);
+        CountDownLatch releaseSecond = new CountDownLatch(1);
+        TaskQueue queue = new TaskQueue();
+        ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, 
TimeUnit.SECONDS, queue,
+                Executors.defaultThreadFactory());
+        queue.setParent(pool);
+        FutureTask<Void> cancelled = new FutureTask<>(() -> null) {
+            @Override
+            public boolean isCancelled() {
+                // The worker processes the cancelled future before starting 
the second task.
+                releaseFirst.countDown();
+                try {
+                    awaitStarted(secondStarted);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new AssertionError(e);
+                }
+                return super.isCancelled();
+            }
+        };
+        try {
+            pool.execute(blockingTask("first", firstStarted, releaseFirst));
+            awaitStarted(firstStarted);
+            Assert.assertTrue(cancelled.cancel(false));
+            pool.execute(cancelled);
+            pool.execute(blockingTask("second", secondStarted, releaseSecond));
+            pool.purge();
+
+            Assert.assertEquals("Only the second task is outstanding", 1, 
pool.getSubmittedCount());
+            releaseSecond.countDown();
+            awaitSubmittedCount(pool, 0);
+        } finally {
+            releaseFirst.countDown();
+            releaseSecond.countDown();
+            pool.shutdownNow();
+            Assert.assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
+        }
+    }
+
+
     /*
      * If beforeExecute() throws, the worker dies without processing the task 
and without calling afterExecute(), so the
      * submitted count must be decremented for the lost task.
@@ -217,6 +354,43 @@ public class TestThreadPoolExecutor {
         }
     }
 
+    /*
+     * If the pool is shut down after a task has been queued but before 
execute() re-checks the pool state, execute()
+     * removes the task again and rejects it. The removal can empty the queue 
of a shut down pool that has no workers,
+     * so it must trigger the transition to terminated since nothing else will.
+     */
+    @Test
+    public void testRollbackOfQueuedTaskDuringShutdownTerminatesPool() throws 
Exception {
+        class ShutdownAfterOfferQueue extends LinkedBlockingQueue<Runnable> {
+            private static final long serialVersionUID = 1L;
+            private transient volatile ThreadPoolExecutor pool;
+
+            @Override
+            public boolean offer(Runnable r) {
+                boolean result = super.offer(r);
+                // Shut down while the task is queued, as a concurrent 
shutdown() call could
+                pool.shutdown();
+                return result;
+            }
+        }
+
+        ShutdownAfterOfferQueue queue = new ShutdownAfterOfferQueue();
+        // No core threads so there is no worker to terminate the pool once 
the queue has been emptied
+        ThreadPoolExecutor pool = new ThreadPoolExecutor(0, 1, 60, 
TimeUnit.SECONDS, queue,
+                Executors.defaultThreadFactory(), new 
ThreadPoolExecutor.AbortPolicy());
+        queue.pool = pool;
+        try {
+            Assert.assertThrows(RejectedExecutionException.class, () -> 
pool.execute(() -> {
+                // Must not run
+            }));
+            Assert.assertTrue("Pool did not terminate", 
pool.awaitTermination(5, TimeUnit.SECONDS));
+            Assert.assertEquals(0, pool.getSubmittedCount());
+        } finally {
+            pool.shutdownNow();
+        }
+    }
+
+
     /*
      * Tasks that complete normally or that throw must each decrement the 
submitted count exactly once.
      */


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

Reply via email to