Skip to content

Fix ThreadPool.shrink() accounting of pending shutdowns - #859

Open
rm-you wants to merge 1 commit into
cherrypy:mainfrom
rm-you:fix/threadpool-shrink-accounting
Open

rm-you wants to merge 1 commit into
cherrypy:mainfrom
rm-you:fix/threadpool-shrink-accounting

Conversation

@rm-you

@rm-you rm-you commented Oct 19, 2026 •

Copy link
Copy Markdown

What kind of change does this PR introduce?

  • 🐞 bug fix
  • 🐣 feature
  • 📋 docs update
  • 📋 tests/coverage improvement
  • 📋 refactoring
  • 💥 other

What do these changes do?

shrink() subtracted the shutdowns that earlier calls had queued from the amount it was asked for, before it removed the threads that had already exited. So a call made before an earlier call's threads were reaped was mostly ignored. idle already leaves those threads out, so a caller that sizes the amount from it, as dynpool does, had them subtracted twice. The threads above the minimum were also counted together with those already shutting down, so the pool could drop below min.

stop() queued a shutdown request for every thread it listed, including those that shrink() had already retired but that were not reaped yet, and never reset _pending_shutdowns. The requests left over then stopped the threads of the next start(), and its grow() waited for them forever. HTTPServer.prepare() on a stopped server starts the pool again, as CherryPy does when its bus restarts.

shrink() now asks up to that many more threads to shut down, among those above the minimum that are not shutting down yet. stop() queues one request for each live thread that shrink() has not asked already, and forgets the pending shutdowns once the threads are joined.

cheroot 11.1.2 this change
4 × shrink(20) on 90 threads, min 10 70 left 10 left
shrink(40), then shrink(80), on 50 threads, min 10 0 left, 30 requests queued 10 left, none queued
start() after shrink() and stop() waits forever returns

The four new tests fail without the change, including one that stops the pool while a shrink is still pending; the restart test fails by a timeout rather than hanging the run.

Are there changes in behavior for the user?

shrink(amount) now retires up to amount more threads, counting those already shutting down as gone, as idle does, and never below min. #204 settled that amount counts the shutdowns already scheduled. That still holds for a caller that sizes it from idle, but one that subtracts pending shutdowns itself would now shrink more. Nothing in cheroot or CherryPy calls shrink().

Is it a substantial burden for the maintainers to support this?

No. It changes how two methods count, in a few lines, and the new tests cover all three cases.

Related issue number

Fixes #861. A related statistics retention problem, #860, is fixed in #858.

Checklist

Tip

New to open source contributions? Making a lovely PR is a great read.

  • I wrote descriptive pull request text above
  • The PR relates to only one subject with a clear title
    and description in grammatically correct, complete sentences
  • I think the code is well written
  • Unit tests for the changes exist
  • Integration tests for the changes exist (if applicable)
  • I used the same coding conventions as the rest of the project
  • The new code doesn't generate linter offenses
  • Project documentation (in docs/) and inline docstrings reflect the changes
  • My commits each have a descriptive title and a body explaining the why
    (see good commit messages)
  • I have added a change log entry
    (can be done once the PR number is known; alternatively, you can
    use a related issue number if one exists)
  • I'm planning to squash related commits together before final merge
  • I have read the contribution guide and the
    code of conduct

This change, its tests and this description were written by an AI agent (Anthropic Claude Opus 5.5) on behalf of Adam Harwell (@rm-you). The tests were run locally with Python 3.11 (217 passed, 3 existing expected failures), and with #858 merged on Python 3.11, 3.12 and 3.13; all pre-commit hooks passed on all files, including mypy-stubtest with Python 3.12.

🤖 Generated with Claude Code

@agentscanapp

agentscanapp Bot commented Oct 19, 2026

Copy link
Copy Markdown

Mixed activity

Activity patterns show a mix of organic and automated signals.

View full analysis →

Evidence
  • Very high PR volume (daily): 38 PRs within a single 24-hour window

Last 5 PRs:

This is an automated analysis by AgentScan

@read-the-docs-community

read-the-docs-community Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Documentation build overview

📚 cheroot | 🛠️ Build #35632778 | 📁 Comparing 4408b02 against latest (8402e55)

  🔍 Preview build  

5 files changed · ± 5 modified

± Modified

@rm-you
rm-you force-pushed the fix/threadpool-shrink-accounting branch from d6337a6 to 1df9482 Compare October 19, 2026 08:12
@pyba-chronographer pyba-chronographer Bot added the bot:chronographer:provided A mark meaning that a new change log entry is present within the patch. label Oct 19, 2026
@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.69%. Comparing base (4b11bf8) to head (4408b02).
⚠️ Report is 37 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #859      +/-   ##
==========================================
+ Coverage   78.20%   78.69%   +0.48%     
==========================================
  Files          41       41              
  Lines        4791     4835      +44     
  Branches      548      554       +6     
==========================================
+ Hits         3747     3805      +58     
+ Misses        905      891      -14     
  Partials      139      139              

@rm-you

rm-you commented Oct 19, 2026 •

Copy link
Copy Markdown
Author

Hey, just dropping a quick note here since I just posted this and #858 with Claude, as I want to be clear that while Claude wrote most/all of the code here, there is a real human behind these driving and doing first-pass reviews, and these are bugs I've been tracking internally for quite a while.

We use cheroot to serve parts of an in-development RPC system in OpenStack's oslo.messaging project, and we trip over this periodically depending on deployment style. I will be tracking these and am happy to make adjustments based on feedback, or drop these PRs if there's a better approach from someone who knows the codebase better than I (and AI) do. Thanks!

@rm-you
rm-you marked this pull request as ready for review October 19, 2026 10:23
@rm-you

rm-you commented Oct 19, 2026

Copy link
Copy Markdown
Author

I'm pretty sure the failed test is not related to this, maybe someone can re-run it?

@webknjaz

Copy link
Copy Markdown
Member

Hi, thanks for the PR. Can't promise timely reviews but maybe @julianz- will be able to spare a minute. Feel free to drop by our matrix. I'm sometimes more responsive there.

I've restarted the job, it seems like it hit GHA-level timeout while also having been marked as green.

It might be wise to tag people who looked at this and some surrounding modules last, just in case. cc @pR0Ps @itamarst

@webknjaz
webknjaz requested a review from julianz- October 21, 2026 14:41
@rm-you

rm-you commented Oct 21, 2026

Copy link
Copy Markdown
Author

Thanks, no worries. I have some workarounds on my side in my oslo.messaging patches, but obviously that's not ideal long term, and I figured someone else may have run into this.

@julianz- julianz- mentioned this pull request Oct 22, 2026
10 of 18 tasks

@julianz- julianz- left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall logic seems fine but I have reworded various descriptions to make them easier to understand.

Comment thread cheroot/test/test_server.py Resolved
Comment thread cheroot/test/test_server.py Resolved
Comment thread cheroot/test/test_server.py Resolved
Comment thread cheroot/test/test_server.py Resolved
Comment thread cheroot/test/test_server.py Resolved
Comment thread cheroot/workers/threadpool.py Resolved
Comment thread cheroot/workers/threadpool.py Resolved
Comment thread cheroot/test/test_server.py Resolved
@rm-you

rm-you commented Oct 29, 2026

Copy link
Copy Markdown
Author

I pushed one more commit, fe81ecd, which only adds a test: it stops the
pool while a shrink is still pending, then checks that nothing stays
queued and that the restarted pool reports its idle threads correctly.
Nothing else changed. Without the _pending_shutdowns.clear() line in
stop(), this new test is the only one that fails.

rm-you added a commit to rm-you/cheroot that referenced this pull request Oct 29, 2026
Define _wait_until() after the module's markers instead of among the
statistics tests. The thread pool tests of cherrypy#859 need the same helper
and add it, byte for byte, at the same place, so the two changes merge
into a single definition whichever lands first.

Assisted-By: Anthropic Claude Opus 5.5
Signed-off-by: Adam Harwell <flux.adam@gmail.com>
@rm-you

rm-you commented Oct 29, 2026

Copy link
Copy Markdown
Author

One more small test-only commit, b25eaf2: the thread pool tests now use a general _wait_until() helper instead of _wait_for_live_threads(). #858 adds the same helper, byte for byte, at the same place in test_server.py, so whichever PR merges second rebases without a conflict and the file keeps one copy. The tests themselves are unchanged and still fail without the fix.

rm-you added a commit to rm-you/cheroot that referenced this pull request Nov 4, 2026
Define _wait_until() after the module's markers instead of among the
statistics tests. The thread pool tests of cherrypy#859 need the same helper
and add it, byte for byte, at the same place, so the two changes merge
into a single definition whichever lands first.

Assisted-By: Anthropic Claude Opus 5.5
Signed-off-by: Adam Harwell <flux.adam@gmail.com>
@rm-you
rm-you force-pushed the fix/threadpool-shrink-accounting branch from b25eaf2 to d641734 Compare November 4, 2026 03:34
shrink() subtracted the shutdowns that earlier calls had queued from the
amount it was asked for, before it removed the threads that had already
exited. So a call made before an earlier call's threads were reaped was
mostly ignored: four shrink(20) calls in a row retired 20 of 90 threads,
and 40 when made a second apart. The idle property already leaves those
threads out, so a caller that sizes the amount from it had them
subtracted twice. The threads above the minimum were also counted with
those already shutting down: shrink(40), then shrink(80), on 50 threads
with a minimum of 10 retired all 50, and left 30 requests queued.

shrink() now asks up to that many more threads to shut down, among
those above the minimum that are not shutting down yet.

stop() queued a request for every thread in its list, including the
threads that shrink() had already asked to shut down, so requests were
left in the queue. The next start() lost its new threads to them and
waited for those threads forever. stop() also kept the pending
shutdowns counted, so a restarted pool reported none of its threads
idle, and its next stop() queued too few requests and waited for its
threads forever. stop() now queues a request only for each thread that
shrink() has not asked already, and forgets the pending shutdowns once
it has joined the threads.

The tests use a general _wait_until() helper. The statistics tests of
cherrypy#858 add the same helper, byte for byte, at the same place, so the two
changes merge into a single definition whichever lands first.

Assisted-By: Anthropic Claude Opus 5.5
Assisted-By: OpenAI Codex (GPT-6)
Signed-off-by: Adam Harwell <flux.adam@gmail.com>
rm-you added a commit to rm-you/cheroot that referenced this pull request Nov 4, 2026
Every HTTPServer registered its statistics in the process-wide
logging.statistics when it was constructed, and nothing removed them.
Their callbacks hold the server, so every server ever built stayed
alive with its gateway: stopped ones, ones whose prepare() failed to
bind, and ones that never started. A server is now registered only
while it serves, from prepare() to stop(), and clear_stats() replaces
its entry while it serves.

Every worker thread also left its entry in its server's Worker Threads
statistics when it exited, so a pool that grows and shrinks again kept
every thread it ever started. Each worker now registers with a small
collector that clear_stats() creates. On exit, the worker adds its
final counters to the collector's retired totals and removes its
record, so the server totals still count its work: requests, bytes
read and written, work time, and both throughputs, which remain the
sum of each worker's rate. One lock covers registering, retiring and
reading the totals, so a retiring worker is counted exactly once. A
worker retires into the collector it joined, so one that outlives
clear_stats() cannot bring the cleared counters back. A server whose
own clear_stats() creates no collector keeps no worker records, rather
than failing to start.

The tests define a general _wait_until() helper after the module's
markers. The thread pool tests of cherrypy#859 add the same helper, byte for
byte, at the same place, so the two changes merge into a single
definition whichever lands first.

Assisted-By: Anthropic Claude Opus 5.5
Assisted-By: OpenAI Codex (GPT-6)
Signed-off-by: Adam Harwell <flux.adam@gmail.com>
@rm-you
rm-you force-pushed the fix/threadpool-shrink-accounting branch from d641734 to 4408b02 Compare November 4, 2026 04:26

@webknjaz webknjaz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite comfortable with this part of the code base so I'm going to need more people to give opinions. And maybe run some agentic review against this too.

Maybe @julianz- / @jaraco / @the-allanc / @cyraxjoe could spare a minute.

)


def _wait_until(condition, timeout=10):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should start using kwarg-only params in new code for readability (start the param def with an asterisk). Also, I believe, this might be called a predicate, eh?

Although, it's not obvious that it's callable from the name so we should probably use a verb like compute_condition so when it's called later, it doesn't look that weird.

Also, I recommend adding type annotations for all newly added code).


def shrink(self, amount):
"""Kill off worker threads (not below self.min)."""
# Grow/shrink the pool if necessary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you looked into git diff (maybe over in cherrypy/cherrypy where the original Git tree came from) to see why the comment said "grow"? I just want to make sure that by removing the comment we don't lose any breadcrumbs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this predates @the-allanc's refactoring introduced in #204 — I'm somewhat scared to touch that self._pending_shutdowns that self._clear_dead_threads() mutates.
But the comment itself seems to have existed even before the module move (c9f9d72).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment first appeared in cherrypy/cherrypy@d070ca0, back when nobody knew how to deal with maintaining a dual python2+python3 codebase, right before six was invented.

@webknjaz

Copy link
Copy Markdown
Member

@julianz- this needs a more in-depth review now with full approval if all's good.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Shutdown accounting during concurrent or interrupted shrink() can queue too few shutdown requests, causing stop() to hang indefinitely despite its timeout.

2 open issues
What changed in this PR

This PR addresses #861 by updating Cheroot’s thread-pool shutdown accounting to preserve the minimum pool size and support restarting after shrinking and stopping.

Changes:

  • Make repeated shrink() calls request additional retirements without double-counting pending shutdowns.
  • Adjust stop() to account for existing shutdown requests and clear pending state afterward.
  • Add four regression cases covering repeated shrinking, minimum-size preservation, and restarts.
File Description
docs/​changelog-fragments.d/​859.bugfix.rst Document shutdown-accounting fixes and restart behavior.
cheroot/​workers/​threadpool.py Update shrink limits, shutdown-request counts, and pending-state cleanup.
cheroot/​test/​test_server.py Add fixtures, waiting helpers, and thread-pool regression tests.

Review effort: Max

# Some threads may already have a shutdown request from an earlier
# shrink() call. Add a request for every thread that does not have
# one yet.
for _ in range(len(self._threads) - len(self._pending_shutdowns)):
# Some threads may already have a shutdown request from an earlier
# shrink() call. Add a request for every thread that does not have
# one yet.
for _ in range(len(self._threads) - len(self._pending_shutdowns)):

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agentscan:mixed-signals bot:chronographer:provided A mark meaning that a new change log entry is present within the patch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ThreadPool.shrink() miscounts pending shutdowns, and a stopped pool can hang on restart

4 participants