-
Notifications
You must be signed in to change notification settings - Fork 22
fix: reap stale netius HTTP clients from dead threads #85
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -908,10 +908,20 @@ def _client_netius(level=logging.CRITICAL): | |||||||||||||
| if netius_client: | ||||||||||||||
| return netius_client | ||||||||||||||
|
|
||||||||||||||
| # reaps stale clients from dead threads before creating | ||||||||||||||
| # a new one, this prevents unbounded growth of the global | ||||||||||||||
| # clients map in long-running multi-threaded applications | ||||||||||||||
| _reap_netius_clients() | ||||||||||||||
|
|
||||||||||||||
| # creates the "new" HTTP client for the current thread and registers | ||||||||||||||
| # it under the netius client structure so that it may be re-used | ||||||||||||||
| # it under the netius client structure so that it may be re-used, the | ||||||||||||||
| # insertion is done inside the lock to avoid TOCTOU race conditions | ||||||||||||||
| netius_client = netius.clients.HTTPClient(auto_release=False) | ||||||||||||||
| _netius_clients[tid] = netius_client | ||||||||||||||
| ACCESS_LOCK.acquire() | ||||||||||||||
| try: | ||||||||||||||
| _netius_clients[tid] = netius_client | ||||||||||||||
| finally: | ||||||||||||||
| ACCESS_LOCK.release() | ||||||||||||||
|
|
||||||||||||||
| # in case this is the first registration of the dictionary a new on | ||||||||||||||
| # exit callback is registered to cleanup the netius infra-structure | ||||||||||||||
|
|
@@ -983,6 +993,26 @@ def _callback(protocol, parser, message): | |||||||||||||
| return extra | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _reap_netius_clients(): | ||||||||||||||
| global _netius_clients | ||||||||||||||
| if not _netius_clients: | ||||||||||||||
| return | ||||||||||||||
| live_tids = set(t.ident for t in threading.enumerate()) | ||||||||||||||
| ACCESS_LOCK.acquire() | ||||||||||||||
| try: | ||||||||||||||
| dead_tids = [tid for tid in _netius_clients if tid not in live_tids] | ||||||||||||||
| for tid in dead_tids: | ||||||||||||||
| client = _netius_clients.pop(tid, None) | ||||||||||||||
| if not client: | ||||||||||||||
|
||||||||||||||
| if not client: | |
| if client is None: |
Copilot
AI
Apr 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The except Exception: pass around client.cleanup() suppresses all cleanup failures silently, making resource leaks/debugging much harder in production. Consider at least logging the exception (possibly at debug level) before continuing, or narrowing the caught exception types if there are known benign failures.
| pass | |
| logging.getLogger(__name__).debug( | |
| "Failed to cleanup netius client for dead thread %s", | |
| tid, | |
| exc_info=True, | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ | |
| """ The license for the module """ | ||
|
|
||
| import unittest | ||
| import unittest.mock | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Importing Useful? React with 👍 / 👎. |
||
| import threading | ||
|
|
||
| import appier | ||
|
|
@@ -262,3 +263,50 @@ def test_invalid(self): | |
| self.assertRaises( | ||
| BaseException, lambda: appier.get("https://invalidlargedomain.org/") | ||
| ) | ||
|
|
||
| def test_reap_netius_clients(self): | ||
| dead_client = unittest.mock.MagicMock() | ||
| alive_client = unittest.mock.MagicMock() | ||
|
|
||
| alive_tid = threading.current_thread().ident | ||
| dead_tid = 99999999 | ||
|
|
||
|
Comment on lines
+271
to
+273
|
||
| original = getattr(appier.http, "_netius_clients", None) | ||
| try: | ||
| appier.http._netius_clients = { | ||
| dead_tid: dead_client, | ||
| alive_tid: alive_client, | ||
| } | ||
|
|
||
| appier.http._reap_netius_clients() | ||
|
|
||
| self.assertEqual(dead_tid in appier.http._netius_clients, False) | ||
| self.assertEqual(alive_tid in appier.http._netius_clients, True) | ||
| dead_client.cleanup.assert_called_once() | ||
| alive_client.cleanup.assert_not_called() | ||
| finally: | ||
| if original is None: | ||
| if hasattr(appier.http, "_netius_clients"): | ||
| del appier.http._netius_clients | ||
| else: | ||
| appier.http._netius_clients = original | ||
|
|
||
| def test_client_netius_cleanup(self): | ||
| dead_client = unittest.mock.MagicMock() | ||
| dead_tid = 99999999 | ||
|
|
||
|
Comment on lines
+295
to
+297
|
||
| original = getattr(appier.http, "_netius_clients", None) | ||
| try: | ||
| appier.http._netius_clients = {dead_tid: dead_client} | ||
|
|
||
| appier.http._reap_netius_clients() | ||
|
|
||
| self.assertEqual(dead_tid in appier.http._netius_clients, False) | ||
| self.assertEqual(len(appier.http._netius_clients), 0) | ||
| dead_client.cleanup.assert_called_once() | ||
| finally: | ||
| if original is None: | ||
| if hasattr(appier.http, "_netius_clients"): | ||
| del appier.http._netius_clients | ||
| else: | ||
| appier.http._netius_clients = original | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
live_tidsis captured before acquiringACCESS_LOCK, so another thread can create/insert its client into_netius_clientsafter the snapshot but before the sweep. That thread’s TID won’t be inlive_tids, causing its (live) client to be incorrectly reaped/cleaned up. Computelive_tidswhile holdingACCESS_LOCK(and ideally also move the empty-check under the lock) so the snapshot + sweep are atomic w.r.t. insertions.