2df5fc94a3
A fresh SnmpEngine() was created on every poll_device() call and never closed. pysnmp opens a UDP transport socket per engine and doesn't release it on GC, so each scheduler tick (default 60s, per device) leaked a file descriptor. Over hours of polling the process hit its fd ceiling and the listening socket could no longer accept connections — OSError: [Errno 24] Too many open files on socket.accept(), locking up the app. Wrap the engine in try/finally and release its transport socket via a new _close_engine() helper that probes both pysnmp API shapes (6.2.x lextudio camelCase, canonical 7.x snake_case); all close paths are best-effort so a failed close never breaks the poll loop. Regression tests cover both shapes and the never-raises contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
77 lines
2.5 KiB
Python
77 lines
2.5 KiB
Python
"""Unit tests for the SNMP poller (no pysnmp / no network).
|
|
|
|
The unit lane doesn't install the `snmp` extra, so these exercise the parts that
|
|
don't need pysnmp: the version→mpModel mapping, that the poller is async, and the
|
|
graceful "pysnmp missing → empty result" path. Live polling against a real device
|
|
is verified out-of-band (CI has no SNMP target). Uses asyncio.run() directly so it
|
|
doesn't depend on the pytest-asyncio mode.
|
|
"""
|
|
import asyncio
|
|
import inspect
|
|
|
|
from plugins.snmp import poller
|
|
|
|
|
|
def test_poll_device_is_coroutine():
|
|
# The scheduler awaits it directly (no executor) — it must be async.
|
|
assert inspect.iscoroutinefunction(poller.poll_device)
|
|
|
|
|
|
def test_mp_model_maps_version_to_int():
|
|
assert poller._mp_model("1") == 0 # SNMP v1
|
|
assert poller._mp_model("2c") == 1 # SNMP v2c
|
|
assert poller._mp_model("2") == 1 # anything non-"1" → v2c model
|
|
|
|
|
|
def test_poll_device_without_pysnmp_returns_empty(monkeypatch):
|
|
# When the optional dep is absent, polling is disabled gracefully (no raise).
|
|
monkeypatch.setattr(poller, "_pysnmp_available", lambda: False)
|
|
out = asyncio.run(
|
|
poller.poll_device("192.0.2.1", 161, "public", "2c", [{"oid": "1.3.6.1.2.1.1.3.0"}])
|
|
)
|
|
assert out == {}
|
|
|
|
|
|
# --- _close_engine: the fd-leak guard ---------------------------------------
|
|
# A fresh SnmpEngine per poll leaks its UDP socket unless closed; _close_engine
|
|
# must release it across both pysnmp API shapes. These fakes stand in for the
|
|
# engine since the unit lane has no pysnmp.
|
|
|
|
def test_close_engine_uses_engine_level_close_7x():
|
|
# canonical pysnmp 7.x: close method lives directly on the engine.
|
|
calls = []
|
|
|
|
class Engine:
|
|
def close_dispatcher(self):
|
|
calls.append("engine")
|
|
|
|
poller._close_engine(Engine())
|
|
assert calls == ["engine"]
|
|
|
|
|
|
def test_close_engine_falls_back_to_dispatcher_6x():
|
|
# pysnmp-lextudio 6.2.x: no engine-level close; go through the dispatcher.
|
|
calls = []
|
|
|
|
class Dispatcher:
|
|
def closeDispatcher(self):
|
|
calls.append("dispatcher")
|
|
|
|
class Engine:
|
|
transportDispatcher = Dispatcher()
|
|
|
|
poller._close_engine(Engine())
|
|
assert calls == ["dispatcher"]
|
|
|
|
|
|
def test_close_engine_never_raises():
|
|
# A failing close must not break the poll loop.
|
|
class Engine:
|
|
def close_dispatcher(self):
|
|
raise OSError("boom")
|
|
|
|
poller._close_engine(Engine()) # no exception
|
|
|
|
# No close path at all (defensive) is also fine.
|
|
poller._close_engine(object())
|