Skip to content

Commit 7d5c009

Browse files
committed
v0.4.0: GitHub Actions CI and graceful shutdown
Add CI workflow (flake8 + pytest on Python 3.12/3.13). Handle SIGINT/SIGTERM in both single-process and multiprocessing modes — workers are terminated cleanly, syslog closed, no orphans.
1 parent 39ca07e commit 7d5c009

4 files changed

Lines changed: 99 additions & 20 deletions

File tree

.github/workflows/ci.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.12"
17+
- run: pip install flake8
18+
- run: flake8 src/ tests/
19+
20+
test:
21+
runs-on: ubuntu-latest
22+
strategy:
23+
matrix:
24+
python-version: ["3.12", "3.13"]
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: actions/setup-python@v5
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
- run: pip install -e ".[dev]"
31+
- run: pytest -m "not integration" -v

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.4.0] - 2026-04-06
8+
9+
### Added
10+
- **GitHub Actions CI** — runs flake8 lint and unit tests on every push/PR to main, across Python 3.12 and 3.13
11+
- **Graceful shutdown** — handles SIGINT (Ctrl+C) and SIGTERM in both single-process and multiprocessing modes:
12+
- Single-process: `KeyboardInterrupt` caught, syslog closed cleanly
13+
- Multiprocessing: signal handler terminates all worker processes, drains remaining results, joins workers with timeout
14+
715
## [0.3.0] - 2026-04-06
816

917
### Changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "snmp-poller"
7-
version = "0.3.0"
7+
version = "0.4.0"
88
description = "Concurrent SNMPv3 polling application using asyncio"
99
readme = "README.md"
1010
license = "MIT"

src/snmp_poller/poller.py

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import json
55
import multiprocessing
6+
import signal
67
import syslog
78

89
from pysnmp.hlapi.v3arch.asyncio import get_cmd
@@ -225,10 +226,11 @@ async def _run():
225226

226227
def _run_multiprocess(records, snmp_params, oids,
227228
engine_pool_size, output_file,
228-
logging_path, num_workers):
229+
logging_path, num_workers, logger):
229230
'''
230231
Distribute hosts across worker processes, collect results
231232
centrally, and write output from the supervisor process.
233+
Handles SIGINT/SIGTERM to terminate workers cleanly.
232234
'''
233235
chunks = _partition_hosts(records, num_workers)
234236
result_queue = multiprocessing.Queue()
@@ -249,25 +251,56 @@ def _run_multiprocess(records, snmp_params, oids,
249251
processes.append(p)
250252

251253
active_workers = len(processes)
254+
shutting_down = False
255+
256+
def _shutdown(signum, frame):
257+
nonlocal shutting_down
258+
if shutting_down:
259+
return
260+
shutting_down = True
261+
sig_name = signal.Signals(signum).name
262+
logger.info(f'Received {sig_name}, stopping workers...')
263+
for p in processes:
264+
if p.is_alive():
265+
p.terminate()
266+
267+
signal.signal(signal.SIGINT, _shutdown)
268+
signal.signal(signal.SIGTERM, _shutdown)
252269

253270
# Drain results and write centrally — single file handle,
254271
# no contention between processes.
255272
syslog.openlog(facility=syslog.LOG_LOCAL1)
256273
workers_done = 0
257-
with open(output_file, 'a') as f:
258-
while workers_done < active_workers:
259-
item = result_queue.get()
260-
if item is None:
261-
workers_done += 1
262-
continue
263-
output = json.dumps(item, indent=2)
264-
syslog.syslog(output)
265-
f.write(output + '\n')
266-
267-
for p in processes:
268-
p.join()
274+
try:
275+
with open(output_file, 'a') as f:
276+
while workers_done < active_workers:
277+
try:
278+
item = result_queue.get(timeout=1)
279+
except Exception:
280+
# Check if workers died or we're shutting down.
281+
if shutting_down:
282+
break
283+
alive = [p for p in processes if p.is_alive()]
284+
if not alive:
285+
break
286+
continue
287+
if item is None:
288+
workers_done += 1
289+
continue
290+
output = json.dumps(item, indent=2)
291+
syslog.syslog(output)
292+
f.write(output + '\n')
293+
finally:
294+
# Ensure all workers are stopped and joined.
295+
for p in processes:
296+
if p.is_alive():
297+
p.terminate()
298+
for p in processes:
299+
p.join(timeout=5)
300+
syslog.closelog()
269301

270-
syslog.closelog()
302+
if shutting_down:
303+
logger.info('Shutdown complete.')
271304

272305

273306
def main():
@@ -284,7 +317,7 @@ def main():
284317
output_file = paths['output_file']
285318

286319
if num_workers <= 1:
287-
# Single-process mode — existing behavior.
320+
# Single-process mode.
288321
engine_pool = [
289322
PySnmpInit(
290323
snmp_params['userName'],
@@ -302,14 +335,21 @@ def main():
302335
engine_pool[i % pool_size],
303336
snmp_params, output_file, logger,
304337
)
305-
for i, (host, group) in enumerate(records.items())
338+
for i, (host, group) in enumerate(
339+
records.items()
340+
)
306341
]
307-
asyncio.run(asyncio.gather(*tasks))
308342

309-
syslog.closelog()
343+
try:
344+
asyncio.run(asyncio.gather(*tasks))
345+
except KeyboardInterrupt:
346+
logger.info('Interrupted, shutting down.')
347+
finally:
348+
syslog.closelog()
310349
else:
311350
# Multiprocessing mode — distribute across workers.
312351
_run_multiprocess(
313352
records, snmp_params, oids, pool_size,
314-
output_file, paths['logging_path'], num_workers,
353+
output_file, paths['logging_path'],
354+
num_workers, logger,
315355
)

0 commit comments

Comments
 (0)