Skip to content

Commit a747996

Browse files
authored
Added explicit teardown (#62)
* Added explicit teardown * fixed teardown test
1 parent 52b6243 commit a747996

7 files changed

Lines changed: 189 additions & 4 deletions

File tree

src/birdnet/acoustic/inference/core/perf_tracker.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import birdnet.acoustic.inference.core.logs as bn_logging
2020
from birdnet.acoustic.inference.core.shm import RingField
21-
from birdnet.acoustic.inference.core.sync import CountedSemaphore
21+
from birdnet.acoustic.inference.core.sync import CountedSemaphore, abandon_queue_feeders
2222
from birdnet.globals import READABLE_FLAG, READING_FLAG, WRITABLE_FLAG
2323

2424

@@ -224,6 +224,17 @@ def __call__(self) -> None:
224224
)
225225
self._cancel_event.set()
226226

227+
# On cancellation the parent stops reading these queues; drop any buffered
228+
# results/stats so this process can exit without blocking on its feeder
229+
# threads (see abandon_queue_feeders).
230+
if self._cancel_event.is_set():
231+
abandon_queue_feeders(
232+
self._perf_res,
233+
self._callback_queue,
234+
self._wkr_stats_queue,
235+
self._prd_stats_queue,
236+
)
237+
227238
self._uninit_logging()
228239

229240
def run_main_loop(self) -> None:

src/birdnet/acoustic/inference/core/producer.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import birdnet.acoustic.inference.core.logs as bn_logging
2121
from birdnet.acoustic.inference.core.shm import RingField
22-
from birdnet.acoustic.inference.core.sync import CountedSemaphore
22+
from birdnet.acoustic.inference.core.sync import CountedSemaphore, abandon_queue_feeders
2323
from birdnet.globals import (
2424
READABLE_FLAG,
2525
READING_FLAG,
@@ -552,6 +552,16 @@ def __call__(self) -> None:
552552
)
553553
self._cancel_event.set()
554554

555+
# On cancellation the parent stops reading these queues; drop any buffered
556+
# stats/markers so this process can exit without blocking on its feeder
557+
# threads (see abandon_queue_feeders).
558+
if self._cancel_event.is_set():
559+
abandon_queue_feeders(
560+
self._prod_stats_queue,
561+
self._unprocessed_inputs_queue,
562+
self._completion_queue,
563+
)
564+
555565
self._uninit_logging()
556566

557567
def _run_main_loop(self) -> None:

src/birdnet/acoustic/inference/core/sync.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,33 @@
11
from __future__ import annotations
22

33
import multiprocessing as mp
4+
from multiprocessing import Queue
45
from multiprocessing.sharedctypes import Synchronized
56
from types import TracebackType
67

78

9+
def abandon_queue_feeders(*queues: Queue | None) -> None:
10+
"""Let the current process exit without flushing these queues' feeders.
11+
12+
On the cancellation path the parent stops reading the child->parent queues, so
13+
any items still buffered in a child's feeder thread would otherwise block the
14+
child's shutdown: its exit handler joins the feeder, which is stuck writing to
15+
a full pipe that nobody drains. ``cancel_join_thread`` drops that buffered data
16+
and lets the process exit immediately. Losing the data is fine here -- the run
17+
was cancelled and the results are discarded anyway.
18+
19+
Exiting cleanly this way (instead of being force-terminated by the parent) also
20+
avoids leaking the semaphores the child inherited: a killed process never runs
21+
the finalizers that unregister them from the multiprocessing resource tracker.
22+
23+
Only call this once cancellation is certain; ``None`` entries are skipped so
24+
callers can pass optional queues directly.
25+
"""
26+
for q in queues:
27+
if q is not None:
28+
q.cancel_join_thread()
29+
30+
831
class CountedSemaphore:
932
"""
1033
Drop-in replacement for ``mp.Semaphore`` whose ``get_value()`` works on

src/birdnet/acoustic/inference/core/worker.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import birdnet.acoustic.inference.core.logs as bn_logging
1616
from birdnet.acoustic.inference.core.shm import RingField
17-
from birdnet.acoustic.inference.core.sync import CountedSemaphore
17+
from birdnet.acoustic.inference.core.sync import CountedSemaphore, abandon_queue_feeders
1818
from birdnet.core.backends import BackendLoader, BatchT, VersionedBackendProtocol
1919
from birdnet.globals import (
2020
READABLE_FLAG,
@@ -200,6 +200,12 @@ def __call__(self) -> None:
200200
)
201201
self._cancel_event.set()
202202

203+
# On cancellation the consumer has stopped reading these queues; drop any
204+
# buffered results/stats so this process can exit without blocking on its
205+
# feeder threads (see abandon_queue_feeders).
206+
if self._cancel_event.is_set():
207+
abandon_queue_feeders(self._out_q, self._wkr_stats_queue)
208+
203209
self._uninit_logging()
204210

205211
def run_main_loop(self) -> None:

src/birdnet/acoustic/inference/process_manager.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
if TYPE_CHECKING:
4141
from multiprocessing import Queue
4242

43+
# After a cancelled process is asked to stop (SIGTERM), how long to wait for it
44+
# to actually exit before escalating to SIGKILL. Terminated processes normally
45+
# die within a moment; this only bounds a child that ignores SIGTERM so teardown
46+
# can never hang on it.
47+
_TERMINATE_JOIN_TIMEOUT_S = 5.0
48+
4349

4450
class ProcessManager:
4551
def __init__(
@@ -497,6 +503,7 @@ def _join_processes_after_cancel(
497503
queues = [q for q in queues if q is not None]
498504

499505
deadline = time.monotonic() + grace_period_s
506+
terminated = False
500507

501508
while True:
502509
alive = [p for p in processes if p.is_alive()]
@@ -515,12 +522,20 @@ def _join_processes_after_cancel(
515522
f"Process '{p.name}' did not exit after cancellation; terminating."
516523
)
517524
p.terminate()
525+
terminated = True
518526
break
519527

520528
time.sleep(0.05)
521529

530+
# Reap every process. Ones that exited on their own join instantly; ones we
531+
# just terminated get a bounded wait and are then SIGKILLed if they ignored
532+
# SIGTERM, so a single unresponsive child can never hang teardown.
522533
for p in processes:
523-
p.join()
534+
p.join(timeout=_TERMINATE_JOIN_TIMEOUT_S if terminated else None)
535+
if p.is_alive():
536+
logger.warning(f"Process '{p.name}' ignored termination; killing.")
537+
p.kill()
538+
p.join()
524539
logger.debug(f"Process '{p.name}' finished.")
525540

526541
self._producer_processes = None
@@ -532,3 +547,30 @@ def join_logging(self) -> None:
532547
assert self._logging_thread is not None
533548
self._logging_thread.join()
534549
self._logging_thread = None
550+
551+
def close_queues(self) -> None:
552+
"""Release the parent's handles on the multiprocessing queues.
553+
554+
Call once during teardown, after all child processes and the logging thread
555+
have been joined. Closing is non-blocking (``cancel_join_thread`` first, so
556+
``close`` never waits on a feeder) and lets the OS reclaim the pipes and
557+
semaphores promptly instead of leaving it to garbage collection -- which the
558+
caller may skip entirely (e.g. ``os._exit``).
559+
"""
560+
res = self._res
561+
stats = res.stats_resources
562+
queues: list[Queue | None] = [
563+
res.producer_resources.input_queue,
564+
res.producer_resources.unprocessed_inputs_queue,
565+
res.worker_resources.results_queue,
566+
res.file_completion_resources.marker_queue,
567+
res.logging_resources.logging_queue,
568+
stats.wkr_stats_queue,
569+
stats.prd_stats_queue,
570+
stats.perf_res_queue,
571+
stats.callback_queue,
572+
]
573+
for q in queues:
574+
if q is not None:
575+
q.cancel_join_thread()
576+
q.close()

src/birdnet/acoustic/inference/session.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ def __exit__(self, *args) -> None: # noqa: ANN002
228228
self._resources.logging_resources.stop_logging_event.set()
229229

230230
self._process_manager.join_logging()
231+
self._process_manager.close_queues()
231232
self._process_manager = None
232233

233234
shutil.copyfile(
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Regression tests for cancelling a running prediction session (issue #51).
2+
3+
Cancelling mid-run (e.g. a GUI "stop" button wired to a progress callback) must
4+
raise inside ``run()`` and then let the ``with`` block exit promptly, instead of
5+
hanging in ``ProcessManager.join()`` while it drains the worker queues.
6+
"""
7+
8+
import shutil
9+
import threading
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from birdnet.acoustic.inference.core.perf_tracker import AcousticProgressStats
15+
from birdnet.model_loader import load
16+
from birdnet_tests.test_files import TEST_FILE_LONG
17+
18+
19+
def _load_model(): # noqa: ANN202
20+
return load("acoustic", "2.4", "tf", precision="fp32", library="tflite")
21+
22+
23+
# Generous upper bound on cancel-to-teardown time: we cancel at ~10% progress, so
24+
# only a little inference runs and a clean teardown finishes well under this. Kept
25+
# below the global 300s per-test timeout, and enforced from a helper thread, so a
26+
# regression fails the assertion fast instead of hanging and killing the worker.
27+
_TEARDOWN_DEADLINE_S = 120.0
28+
29+
30+
def test_cancel_from_progress_callback_tears_down_cleanly(tmp_path: Path) -> None:
31+
model = _load_model()
32+
33+
# Enough audio across multiple workers that the run lasts well beyond the first
34+
# progress callback, so cancelling then reliably lands mid-run with many
35+
# segments (and buffered result batches) still outstanding -- exactly the state
36+
# that used to deadlock teardown. The run is cancelled almost immediately, so
37+
# the large file list does not make the test slow.
38+
#
39+
# validate_input_files de-duplicates inputs by absolute path (it collects them
40+
# into a set), so passing the same file N times collapses to a SINGLE input --
41+
# the run then finishes before the first progress callback can cancel it, and
42+
# run() returns without raising. Materialise N distinct copies so the run
43+
# genuinely spans multiple files and workers.
44+
src = Path(TEST_FILE_LONG)
45+
files = [
46+
str(shutil.copyfile(src, tmp_path / f"copy_{i}{src.suffix}")) for i in range(8)
47+
]
48+
49+
holder: dict = {}
50+
cancelled = threading.Event()
51+
52+
def on_progress(stats: AcousticProgressStats) -> None:
53+
# on_progress is only invoked once at least one prediction has been made, so
54+
# the very first call already means the run is under way with work remaining.
55+
session = holder.get("session")
56+
if session is not None and not cancelled.is_set():
57+
cancelled.set()
58+
# Mirrors cancelling from a GUI "stop" button; runs on the dispatcher thread.
59+
session.cancel()
60+
61+
errors: list[BaseException] = []
62+
finished = threading.Event()
63+
64+
def run_session() -> None:
65+
try:
66+
with model.predict_session(
67+
n_workers=2,
68+
top_k=5,
69+
show_stats="progress",
70+
progress_callback=on_progress,
71+
) as session:
72+
holder["session"] = session
73+
with pytest.raises(RuntimeError, match="cancelled"):
74+
session.run(files)
75+
# Reaching here means __exit__ (join + teardown) returned without hanging.
76+
except BaseException as exc: # noqa: BLE001 - surfaced via the assertions below
77+
errors.append(exc)
78+
finally:
79+
finished.set()
80+
81+
worker = threading.Thread(
82+
target=run_session, name="cancel-teardown-test", daemon=True
83+
)
84+
worker.start()
85+
86+
completed = finished.wait(timeout=_TEARDOWN_DEADLINE_S)
87+
assert completed, (
88+
f"session did not tear down within {_TEARDOWN_DEADLINE_S:.0f}s after "
89+
f"cancel() -- ProcessManager.join() likely hung"
90+
)
91+
assert cancelled.is_set(), "progress callback never reached the cancel threshold"
92+
assert not errors, f"unexpected error during cancelled run/teardown: {errors!r}"

0 commit comments

Comments
 (0)