Skip to content

Commit bdbe03a

Browse files
committed
snmp: properly handle SnmpEngine lifetime among threads
pysnmp's AsyncioDispatcher creates a background handle_timeout() task running forever until cancelled. It was leaking with the way Snimpy handled SnmpEngine(). Instead, use one SnmpEngine()/loop by thread and ensure the loop is correctly closed with pening tasks cancelled.
1 parent 9f058e9 commit bdbe03a

File tree

1 file changed

+54
-14
lines changed

1 file changed

+54
-14
lines changed

snimpy/snmp.py

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,65 @@ class SNMPReadOnly(SNMPException):
8282
del obj
8383

8484

85+
class _LoopGuard:
86+
"""Cancel pending tasks and close the event loop on thread exit.
87+
88+
pysnmp's AsyncioDispatcher creates a background handle_timeout()
89+
task that runs forever. When a thread exits, this task must be
90+
properly cancelled and awaited — otherwise asyncio emits "Task
91+
was destroyed but it is pending!" warnings.
92+
93+
This guard is stored in thread-local data alongside the engine
94+
and event loop. Because nothing else references it, it is the
95+
first object to reach refcount 0 when the thread-local dict is
96+
cleared, so its __del__ runs before the engine's dispatcher
97+
tries to clean up the same tasks."""
98+
99+
def __init__(self, loop):
100+
self._loop = loop
101+
102+
def __del__(self):
103+
loop = self._loop
104+
if loop.is_closed():
105+
return
106+
pending = asyncio.all_tasks(loop)
107+
for task in pending:
108+
task.cancel()
109+
if pending:
110+
try:
111+
loop.run_until_complete(
112+
asyncio.gather(*pending, return_exceptions=True))
113+
except RuntimeError:
114+
pass
115+
loop.close()
116+
117+
118+
class _SnimpyEngine:
119+
"""Manage a per-thread SnmpEngine and event loop."""
120+
121+
_tls = threading.local()
122+
123+
@classmethod
124+
def get(cls):
125+
"""Return the per-thread (SnmpEngine, loop) pair."""
126+
if not hasattr(cls._tls, "engine"):
127+
cls._tls.loop = asyncio.new_event_loop()
128+
asyncio.set_event_loop(cls._tls.loop)
129+
cls._tls.engine = SnmpEngine()
130+
cls._tls.guard = _LoopGuard(cls._tls.loop)
131+
return cls._tls.engine, cls._tls.loop
132+
133+
85134
class Session:
86135

87136
"""SNMP session. An instance of this object will represent an SNMP
88137
session. From such an instance, one can get information from the
89138
associated agent."""
90139

91-
_tls = threading.local()
92-
93140
def _run(self, coro):
94-
"""Run an async coroutine synchronously using a thread-local loop."""
95-
if not hasattr(self._tls, "loop"):
96-
self._tls.loop = asyncio.new_event_loop()
97-
return self._tls.loop.run_until_complete(coro)
141+
"""Run an async coroutine synchronously."""
142+
_, loop = _SnimpyEngine.get()
143+
return loop.run_until_complete(coro)
98144

99145
def __init__(self, host,
100146
community="public", version=2,
@@ -148,14 +194,8 @@ def __init__(self, host,
148194
self._host = host
149195
self._version = version
150196
self._none = none
151-
if version == 3:
152-
self._engine = SnmpEngine()
153-
self._contextname = contextname
154-
else:
155-
if not hasattr(self._tls, "engine"):
156-
self._tls.engine = SnmpEngine()
157-
self._engine = self._tls.engine
158-
self._contextname = None
197+
self._engine, _ = _SnimpyEngine.get()
198+
self._contextname = contextname if version == 3 else None
159199
if version == 1 and none:
160200
raise ValueError("None-GET requests not compatible with SNMPv1")
161201

0 commit comments

Comments
 (0)