Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switched start_blocking_portal() to use daemon threads #750

Merged
merged 6 commits into from
Aug 30, 2024

Conversation

agronholm
Copy link
Owner

@agronholm agronholm commented Jun 23, 2024

Changes

ThreadPoolExecutor used daemon threads in Python 3.8 and non-daemon threads in 3.9 onwards. But daemon threads are essential for the use case where we want to have a "loitering" blocking portal which will only be shut down via an atexit hook.

Related discussion: #743

Checklist

If this is a user-facing code change, like a bugfix or a new feature, please ensure that
you've fulfilled the following conditions (where applicable):

  • You've added tests (in tests/) added which would fail without your patch
  • You've updated the documentation (in docs/, in case of behavior changes or new
    features)
  • You've added a new changelog entry (in docs/versionhistory.rst).

If this is a trivial change, like a typo fix or a code reformatting, then you can ignore
these instructions.

Updating the changelog

If there are no entries after the last release, use **UNRELEASED** as the version.
If, say, your patch fixes issue #123, the entry should look like this:

* Fix big bad boo-boo in task groups (#123 <https://github.com/agronholm/anyio/issues/123>_; PR by @yourgithubaccount)

If there's no issue linked, just link to your pull request instead by updating the
changelog after you've created the PR.

…readPoolExecutor

ThreadPoolExecutor used daemon threads in Python 3.8 and non-daemon threads in 3.9 onwards. But daemon threads are essential for the use case where we want to have a "loitering" blocking portal which will only be shut down via an atexit hook.
@dhirschfeld
Copy link

I can confirm this appears to fix the hang I was observing 👍

@agronholm
Copy link
Owner Author

Thanks for confirming!

@dhirschfeld
Copy link

dhirschfeld commented Jun 26, 2024

Possibly a random error which might be fixed by retrying the CI?

FAILED tests/streams/test_tls.py::TestTLSStream::test_extra_attributes[trio] - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread Thread-4
<snip>
ConnectionResetError: [WinError 10054] An existing connection was forcibly closed by the remote host

@agronholm
Copy link
Owner Author

Possibly a random error which might be fixed by retrying the CI?

FAILED tests/streams/test_tls.py::TestTLSStream::test_extra_attributes[trio] - pytest.PytestUnhandledThreadExceptionWarning: Exception in thread Thread-4
<snip>
ConnectionResetError: [WinError 10054] An existing connection was forcibly closed by the remote host

Yup, randomly occurring Windows socket errors are among the hardest to figure out.

Copy link

@dhirschfeld dhirschfeld left a comment

Choose a reason for hiding this comment

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

FWIW it seems to work for me and AFAICT the changes look fine.

@agronholm agronholm requested review from Zac-HD and removed request for Zac-HD August 29, 2024 14:41
Copy link
Collaborator

@gschaffner gschaffner left a comment

Choose a reason for hiding this comment

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

This is great. (We have been using this downstream since you brought the 3.9 ThreadPoolExecutor changes to my attention last year and fixed my implementation.) Thank you!

Comment on lines 486 to 505
thread.start()
try:
portal = future.result()
except BaseException:
thread.join()
raise

if future.done():
portal = future.result()
cancel_remaining_tasks = False
try:
yield portal
except BaseException:
cancel_remaining_tasks = True
raise
finally:
try:
portal.call(portal.stop, cancel_remaining_tasks)
except RuntimeError:
pass
cancel_remaining_tasks = False
try:
yield portal
except BaseException:
cancel_remaining_tasks = True
raise
finally:
try:
portal.call(portal.stop, cancel_remaining_tasks)
except RuntimeError:
pass

run_future.result()
thread.join()
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think removing the branching would make the mandatory-thread.join logic more clearly correct:

    thread.start()
    try:
        cancel_remaining_tasks = False
        portal = future.result()
        try:
            yield portal
        except BaseException:
            cancel_remaining_tasks = True
            raise
        finally:
            try:
                portal.call(portal.stop, cancel_remaining_tasks)
            except RuntimeError:
                pass
    finally:
        thread.join()

(It would also protect against KeyboardInterrupts happening near the line cancel_remaining_tasks = False, although that is unlikely to ever matter, and there will still always be other KI races here.)

This is inconsequential though.

Copy link
Owner Author

Choose a reason for hiding this comment

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

I decided to apply this change.

Comment on lines 615 to 616
async def raise_baseexception() -> None:
raise BaseException("fatal error")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
async def raise_baseexception() -> None:
raise BaseException("fatal error")
async def raise_baseexception() -> None:
assert threading.current_thread().daemon
raise BaseException("fatal error")

@gschaffner
Copy link
Collaborator

One possibility to avoid breaking subinterpreters here could be to make this opt-in: def start_blocking_portal(daemon: bool = False).

@agronholm
Copy link
Owner Author

One possibility to avoid breaking subinterpreters here could be to make this opt-in: def start_blocking_portal(daemon: bool = False).

I'm not too concerned about subinterpreters. I would've loved to add support for them, but they still don't have a stable Python API, and furthermore, once nogil becomes usable, they might become completely obsolete. I think we'll cross that bridge when we come to it, if ever.

@agronholm agronholm merged commit e5a8a93 into master Aug 30, 2024
17 checks passed
@agronholm agronholm deleted the blockingportal-daemon-thread branch August 30, 2024 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants