loghunter-cli 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loghunter/__init__.py +3 -0
- loghunter/cli.py +1108 -0
- loghunter/cli_init.py +567 -0
- loghunter/common/__init__.py +1 -0
- loghunter/common/allowlist.py +436 -0
- loghunter/common/clustering.py +326 -0
- loghunter/common/config.py +221 -0
- loghunter/common/display.py +323 -0
- loghunter/common/errors.py +45 -0
- loghunter/common/finding.py +239 -0
- loghunter/common/loader/__init__.py +136 -0
- loghunter/common/loader/diagnostics.py +94 -0
- loghunter/common/loader/discovery.py +335 -0
- loghunter/common/loader/io.py +76 -0
- loghunter/common/loader/pipeline.py +1010 -0
- loghunter/common/loader/sniff.py +184 -0
- loghunter/common/loader/types.py +207 -0
- loghunter/common/loader/windowing.py +523 -0
- loghunter/common/output.py +93 -0
- loghunter/common/paths.py +105 -0
- loghunter/common/sources.py +392 -0
- loghunter/data/allowlist/connections.txt +50 -0
- loghunter/data/allowlist/domains_devices.txt +5 -0
- loghunter/data/allowlist/domains_homelab.txt +5 -0
- loghunter/data/allowlist/domains_universal.txt +125 -0
- loghunter/data/config_example.toml +144 -0
- loghunter/detectors/__init__.py +5 -0
- loghunter/detectors/auth.py +27 -0
- loghunter/detectors/aws.py +671 -0
- loghunter/detectors/beacon.py +258 -0
- loghunter/detectors/dns.py +778 -0
- loghunter/detectors/dnsblock.py +29 -0
- loghunter/detectors/duration.py +178 -0
- loghunter/detectors/protocol.py +26 -0
- loghunter/detectors/scan.py +735 -0
- loghunter/detectors/ssl.py +25 -0
- loghunter/detectors/syslog.py +266 -0
- loghunter/detectors/weird.py +27 -0
- loghunter/digest/__init__.py +43 -0
- loghunter/digest/_stats.py +182 -0
- loghunter/digest/blob.py +698 -0
- loghunter/digest/cloudtrail.py +341 -0
- loghunter/digest/conn.py +367 -0
- loghunter/digest/dns.py +364 -0
- loghunter/digest/syslog.py +269 -0
- loghunter/exporters/__init__.py +534 -0
- loghunter/exporters/cloudtrail.py +499 -0
- loghunter/exporters/splunk.py +222 -0
- loghunter/outputs/__init__.py +1 -0
- loghunter/outputs/allowlist.py +75 -0
- loghunter/outputs/csv.py +70 -0
- loghunter/outputs/email.py +44 -0
- loghunter/outputs/html.py +99 -0
- loghunter/outputs/json.py +77 -0
- loghunter/outputs/text.py +1422 -0
- loghunter/parsers/__init__.py +1 -0
- loghunter/parsers/cloudtrail.py +287 -0
- loghunter/parsers/dnsmasq.py +331 -0
- loghunter/parsers/syslog.py +150 -0
- loghunter/parsers/zeek.py +294 -0
- loghunter/parsers/zeek_tsv.py +310 -0
- loghunter/runner.py +1895 -0
- loghunter_cli-0.1.0.dev0.dist-info/METADATA +336 -0
- loghunter_cli-0.1.0.dev0.dist-info/RECORD +122 -0
- loghunter_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- loghunter_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- loghunter_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- loghunter_cli-0.1.0.dev0.dist-info/top_level.txt +4 -0
- migrations/cloudtrail_parquet.py +59 -0
- migrations/conn_fft.py +550 -0
- migrations/conn_scan.py +1097 -0
- migrations/dns_dbscan.py +520 -0
- migrations/get_syslog.py +402 -0
- migrations/syslog_drain3.py +479 -0
- scratch/junk/parquet.py +59 -0
- tests/__init__.py +1 -0
- tests/_cloudtrail_fakes.py +116 -0
- tests/conftest.py +17 -0
- tests/test_allowlist_defaults_accessor.py +90 -0
- tests/test_architecture_spine.py +302 -0
- tests/test_aws_detector.py +504 -0
- tests/test_be_like_water.py +106 -0
- tests/test_cli_help.py +342 -0
- tests/test_cli_multi_positional.py +458 -0
- tests/test_cloudtrail_exporter.py +631 -0
- tests/test_cloudtrail_exporter_botocore.py +207 -0
- tests/test_cloudtrail_parser.py +393 -0
- tests/test_clustering.py +85 -0
- tests/test_clustering_interruptible.py +404 -0
- tests/test_config_cli.py +1006 -0
- tests/test_config_example_drift.py +164 -0
- tests/test_digest_blob.py +1237 -0
- tests/test_digest_cli.py +1040 -0
- tests/test_digest_cloudtrail.py +980 -0
- tests/test_digest_conn.py +1189 -0
- tests/test_digest_dns.py +770 -0
- tests/test_digest_stats.py +282 -0
- tests/test_digest_syslog.py +724 -0
- tests/test_display.py +370 -0
- tests/test_dns_detector.py +1010 -0
- tests/test_dnsmasq_parser.py +467 -0
- tests/test_duration_detector.py +491 -0
- tests/test_export_orchestrator_shape.py +153 -0
- tests/test_init_wizard.py +707 -0
- tests/test_loader.py +3639 -0
- tests/test_loader_package_surface.py +115 -0
- tests/test_loader_window_model.py +215 -0
- tests/test_output_path_cascade.py +575 -0
- tests/test_resolve_path.py +111 -0
- tests/test_root_provenance.py +212 -0
- tests/test_runner.py +2599 -0
- tests/test_scan_detector.py +455 -0
- tests/test_search_paths.py +50 -0
- tests/test_sniff_orchestrator.py +373 -0
- tests/test_sniff_recognizers.py +573 -0
- tests/test_source_resolution_seam.py +471 -0
- tests/test_sources.py +648 -0
- tests/test_splunk_exporter.py +351 -0
- tests/test_syslog_detector.py +458 -0
- tests/test_syslog_parser.py +582 -0
- tests/test_text_output.py +1225 -0
- tests/test_zeek_tsv_parser.py +580 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Process-isolation harness for HDBSCAN.fit_predict — Ctrl-C honoured.
|
|
2
|
+
|
|
3
|
+
Covers loghunter.common.clustering.fit_predict_interruptible and its
|
|
4
|
+
helpers. Detector-logic tests live in tests/test_dns_detector.py and
|
|
5
|
+
are intentionally NOT extended here — they flip _CLUSTERING_ISOLATE_ENABLED
|
|
6
|
+
to False via an autouse fixture and exercise the in-process path.
|
|
7
|
+
|
|
8
|
+
Worker targets MUST be module-level so they pickle cleanly under spawn.
|
|
9
|
+
Nested functions, lambdas, and closures would not survive spawn re-import.
|
|
10
|
+
Tests rebind clustering._WORKER_TARGET to the helpers below before
|
|
11
|
+
invoking fit_predict_interruptible; the spawn child re-imports this
|
|
12
|
+
module (its qualified name is tests.test_clustering_interruptible) and
|
|
13
|
+
finds the target by name.
|
|
14
|
+
|
|
15
|
+
All test data is synthetic numpy — no real network data anywhere.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import textwrap
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import pytest
|
|
29
|
+
|
|
30
|
+
from loghunter.common import clustering
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Module-level workers (picklable under spawn) ─────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _blocking_worker(
|
|
37
|
+
result_queue, X, min_cluster_size, min_samples, backend,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Test target: park forever until the parent kills us. Used to
|
|
40
|
+
verify that a parent-side KeyboardInterrupt drives the
|
|
41
|
+
terminate→kill→cleanup sequence and that the child does not leak."""
|
|
42
|
+
while True:
|
|
43
|
+
time.sleep(0.05)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _erroring_worker(
|
|
47
|
+
result_queue, X, min_cluster_size, min_samples, backend,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Test target: report an error tuple and exit. Verifies the parent
|
|
50
|
+
surfaces worker errors as a normal ValueError, not a multiprocessing
|
|
51
|
+
traceback."""
|
|
52
|
+
result_queue.put(("error", "RuntimeError: induced failure"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dying_worker(
|
|
56
|
+
result_queue, X, min_cluster_size, min_samples, backend,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Test target: exit WITHOUT putting anything on the queue. Simulates
|
|
59
|
+
segfault / OOM kill / unhandled signal in the real worker. The parent
|
|
60
|
+
polling rail must raise RuntimeError, not hang forever."""
|
|
61
|
+
sys.exit(7)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _good_worker(
|
|
65
|
+
result_queue, X, min_cluster_size, min_samples, backend,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Test target: return a fixed all-zeros label array."""
|
|
68
|
+
result_queue.put(("ok", np.zeros(len(X), dtype=np.int64)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── 1. Interruptibility — KeyboardInterrupt propagates, child terminated ────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_keyboard_interrupt_propagates_and_terminates_child(
|
|
75
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""A parent-side KeyboardInterrupt during the polling wait causes the
|
|
78
|
+
helper to invoke the committed terminate→join→kill→cleanup→re-raise
|
|
79
|
+
sequence. The KeyboardInterrupt must propagate to the caller; the
|
|
80
|
+
child must be terminated (not zombied); and the helper's cleanup
|
|
81
|
+
must complete without raising (a still-alive child would make
|
|
82
|
+
Process.close() raise — that's the implicit proof the child died).
|
|
83
|
+
|
|
84
|
+
Deterministic harness: patch _await_child_result to raise
|
|
85
|
+
KeyboardInterrupt directly. The polling loop's queue.get is what
|
|
86
|
+
would actually raise when the main thread sees SIGINT; raising
|
|
87
|
+
explicitly avoids racing a real signal in the test process.
|
|
88
|
+
"""
|
|
89
|
+
monkeypatch.setattr(
|
|
90
|
+
clustering, "_WORKER_TARGET", _blocking_worker,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Spy on Process.terminate so we capture state BEFORE the helper's
|
|
94
|
+
# cleanup calls child.close() (which makes the handle unqueryable).
|
|
95
|
+
snapshot: dict = {}
|
|
96
|
+
real_terminate = type(
|
|
97
|
+
clustering.multiprocessing.get_context("spawn").Process(
|
|
98
|
+
target=_good_worker, args=(None, None, 0, 0, "x"),
|
|
99
|
+
)
|
|
100
|
+
).terminate
|
|
101
|
+
|
|
102
|
+
def _spy_terminate(self):
|
|
103
|
+
snapshot["alive_before_terminate"] = self.is_alive()
|
|
104
|
+
return real_terminate(self)
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(
|
|
107
|
+
clustering.multiprocessing.context.SpawnProcess,
|
|
108
|
+
"terminate", _spy_terminate,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _intercepted_await(result_queue, child):
|
|
112
|
+
# Hand the live child to the snapshot dict so a post-cleanup
|
|
113
|
+
# query against its (now-closed) handle isn't required.
|
|
114
|
+
snapshot["proc"] = child
|
|
115
|
+
raise KeyboardInterrupt
|
|
116
|
+
|
|
117
|
+
monkeypatch.setattr(
|
|
118
|
+
clustering, "_await_child_result", _intercepted_await,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
X = np.zeros((10, 2), dtype=np.float64)
|
|
122
|
+
with pytest.raises(KeyboardInterrupt):
|
|
123
|
+
clustering.fit_predict_interruptible(
|
|
124
|
+
X, min_cluster_size=5, min_samples=2,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# The terminate spy fired — proves the helper went down the interrupt
|
|
128
|
+
# cleanup path rather than the normal-return cleanup path. Child was
|
|
129
|
+
# alive when terminate ran (i.e. it was a real running process the
|
|
130
|
+
# interrupt-cleanup had to take down, not a no-op).
|
|
131
|
+
assert snapshot.get("alive_before_terminate") is True
|
|
132
|
+
# The helper completed cleanup without raising from child.close() —
|
|
133
|
+
# which would have raised if the child were still alive at that
|
|
134
|
+
# point. That's the implicit proof that terminate→join→(kill?) drove
|
|
135
|
+
# the child to a terminated state.
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── 2. No resource_tracker leak — subprocess harness ────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.skipif(
|
|
142
|
+
sys.platform.startswith("win"),
|
|
143
|
+
reason="multiprocessing.resource_tracker is a POSIX concern",
|
|
144
|
+
)
|
|
145
|
+
def test_no_resource_tracker_warning_on_interrupt(tmp_path: Path) -> None:
|
|
146
|
+
"""Regression guard: a SIGINT during a stock-hdbscan clustering run
|
|
147
|
+
must terminate the child cleanly with NO resource_tracker 'leaked
|
|
148
|
+
semaphore' message on subprocess stderr.
|
|
149
|
+
|
|
150
|
+
pytest's warnings.catch_warnings does NOT reliably capture
|
|
151
|
+
multiprocessing.resource_tracker output — that line is emitted on
|
|
152
|
+
process-shutdown stderr from a sibling thread the pytest capture
|
|
153
|
+
machinery has already torn down. A subprocess harness captures the
|
|
154
|
+
real stderr, including post-shutdown chatter.
|
|
155
|
+
|
|
156
|
+
Stock hdbscan is the target backend: it spawns the nested
|
|
157
|
+
multiprocessing pool that leaks semaphores. fast_hdbscan uses numba
|
|
158
|
+
threads, so the bug never manifests there. The harness explicitly
|
|
159
|
+
rebinds clustering.HDBSCAN to stock's class — setting
|
|
160
|
+
ACTIVE_BACKEND='hdbscan' alone would leave clustering.HDBSCAN
|
|
161
|
+
pointing at fast_hdbscan.HDBSCAN (which won at module-load time) and
|
|
162
|
+
_build_clusterer would pass core_dist_n_jobs=1 to the wrong class.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
import hdbscan as _stock # noqa: F401
|
|
166
|
+
except ImportError:
|
|
167
|
+
pytest.skip("stock hdbscan not importable")
|
|
168
|
+
|
|
169
|
+
script = tmp_path / "harness.py"
|
|
170
|
+
script.write_text(textwrap.dedent('''
|
|
171
|
+
import os
|
|
172
|
+
import signal
|
|
173
|
+
import threading
|
|
174
|
+
import time
|
|
175
|
+
|
|
176
|
+
import numpy as np
|
|
177
|
+
import hdbscan as _stock
|
|
178
|
+
|
|
179
|
+
from loghunter.common import clustering
|
|
180
|
+
|
|
181
|
+
# Both knobs are load-bearing — ACTIVE_BACKEND drives
|
|
182
|
+
# _build_clusterer's kwarg branch; clustering.HDBSCAN drives
|
|
183
|
+
# the class actually constructed. Setting one without the
|
|
184
|
+
# other silently mis-targets the test.
|
|
185
|
+
clustering.HDBSCAN = _stock.HDBSCAN
|
|
186
|
+
clustering.ACTIVE_BACKEND = "hdbscan"
|
|
187
|
+
|
|
188
|
+
# Reasonably sized synthetic feature matrix so clustering takes
|
|
189
|
+
# long enough for the SIGINT thread below to land mid-compute.
|
|
190
|
+
rng = np.random.default_rng(0)
|
|
191
|
+
X = rng.normal(size=(2000, 4)).astype(np.float64)
|
|
192
|
+
|
|
193
|
+
def _sigint_after():
|
|
194
|
+
time.sleep(0.5)
|
|
195
|
+
os.kill(os.getpid(), signal.SIGINT)
|
|
196
|
+
|
|
197
|
+
threading.Thread(target=_sigint_after, daemon=True).start()
|
|
198
|
+
try:
|
|
199
|
+
clustering.fit_predict_interruptible(
|
|
200
|
+
X, min_cluster_size=50, min_samples=5,
|
|
201
|
+
)
|
|
202
|
+
except KeyboardInterrupt:
|
|
203
|
+
pass
|
|
204
|
+
'''))
|
|
205
|
+
|
|
206
|
+
result = subprocess.run(
|
|
207
|
+
[sys.executable, str(script)],
|
|
208
|
+
capture_output=True, text=True, timeout=60,
|
|
209
|
+
)
|
|
210
|
+
# Harness must complete cleanly — a crash for unrelated reasons
|
|
211
|
+
# would leave the resource_tracker assertions trivially true and
|
|
212
|
+
# silently false-pass the regression. The harness's own
|
|
213
|
+
# except-KeyboardInterrupt arm swallows the interrupt, so a
|
|
214
|
+
# clean exit code is the right expectation. (Glenn CR P2.)
|
|
215
|
+
assert result.returncode == 0, (
|
|
216
|
+
f"harness crashed (rc={result.returncode}); "
|
|
217
|
+
f"stderr={result.stderr!r}"
|
|
218
|
+
)
|
|
219
|
+
# The regression signals — none of these strings may appear in
|
|
220
|
+
# post-interrupt stderr.
|
|
221
|
+
assert "resource_tracker" not in result.stderr, result.stderr
|
|
222
|
+
assert "leaked semaphore" not in result.stderr, result.stderr
|
|
223
|
+
assert "leaked shared_memory" not in result.stderr, result.stderr
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── 3. Equivalence — isolated vs in-process ─────────────────────────────────
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_isolation_does_not_change_labels() -> None:
|
|
230
|
+
"""The isolated path and the in-process escape hatch must produce
|
|
231
|
+
identical label arrays on the same input. Isolation is a control
|
|
232
|
+
affordance, not a numerical change.
|
|
233
|
+
|
|
234
|
+
Uses a synthetic feature matrix with a clear two-cluster structure
|
|
235
|
+
+ a handful of outliers, so HDBSCAN produces a deterministic
|
|
236
|
+
non-trivial label vector both ways.
|
|
237
|
+
"""
|
|
238
|
+
rng = np.random.default_rng(42)
|
|
239
|
+
cluster_a = rng.normal(loc=0.0, scale=0.05, size=(60, 4))
|
|
240
|
+
cluster_b = rng.normal(loc=2.0, scale=0.05, size=(60, 4))
|
|
241
|
+
outliers = rng.uniform(low=-3.0, high=5.0, size=(8, 4))
|
|
242
|
+
X = np.ascontiguousarray(
|
|
243
|
+
np.vstack([cluster_a, cluster_b, outliers]).astype(np.float64),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Run isolated first so any backend-conditional kwarg in the worker
|
|
247
|
+
# is exercised — the in-process run is the calibration reference.
|
|
248
|
+
labels_isolated = clustering.fit_predict_interruptible(
|
|
249
|
+
X, min_cluster_size=10, min_samples=5,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Force the in-process path for the second run.
|
|
253
|
+
saved = clustering._CLUSTERING_ISOLATE_ENABLED
|
|
254
|
+
clustering._CLUSTERING_ISOLATE_ENABLED = False
|
|
255
|
+
try:
|
|
256
|
+
labels_in_process = clustering.fit_predict_interruptible(
|
|
257
|
+
X, min_cluster_size=10, min_samples=5,
|
|
258
|
+
)
|
|
259
|
+
finally:
|
|
260
|
+
clustering._CLUSTERING_ISOLATE_ENABLED = saved
|
|
261
|
+
|
|
262
|
+
assert np.array_equal(labels_isolated, labels_in_process), (
|
|
263
|
+
f"isolated={labels_isolated.tolist()!r} "
|
|
264
|
+
f"in_process={labels_in_process.tolist()!r}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── 4. Backend-conditional kwarg — _build_clusterer direct ──────────────────
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_build_clusterer_stock_passes_core_dist_n_jobs_1(
|
|
272
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Stock hdbscan branch: core_dist_n_jobs=1 must be in the kwargs so
|
|
275
|
+
no nested multiprocessing pool spawns (the source of the leaked-
|
|
276
|
+
semaphore warning). Tested by mocking clustering.HDBSCAN to a
|
|
277
|
+
recording fake and calling _build_clusterer directly — no spawn,
|
|
278
|
+
no escape-hatch path, just the construction surface."""
|
|
279
|
+
recorded: dict = {}
|
|
280
|
+
|
|
281
|
+
class _RecordingHDBSCAN:
|
|
282
|
+
def __init__(self, **kwargs):
|
|
283
|
+
recorded.update(kwargs)
|
|
284
|
+
|
|
285
|
+
monkeypatch.setattr(clustering, "HDBSCAN", _RecordingHDBSCAN)
|
|
286
|
+
|
|
287
|
+
clustering._build_clusterer(
|
|
288
|
+
"hdbscan", min_cluster_size=100, min_samples=10,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
assert recorded == {
|
|
292
|
+
"min_cluster_size": 100,
|
|
293
|
+
"min_samples": 10,
|
|
294
|
+
"core_dist_n_jobs": 1,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_build_clusterer_fast_omits_core_dist_n_jobs(
|
|
299
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""fast_hdbscan branch: core_dist_n_jobs is NOT in the kwargs (it
|
|
302
|
+
isn't in fast_hdbscan's signature; passing it would TypeError).
|
|
303
|
+
Numba threads on the fast backend don't use semaphores, so no
|
|
304
|
+
resource-tracker concern exists there."""
|
|
305
|
+
recorded: dict = {}
|
|
306
|
+
|
|
307
|
+
class _RecordingHDBSCAN:
|
|
308
|
+
def __init__(self, **kwargs):
|
|
309
|
+
recorded.update(kwargs)
|
|
310
|
+
|
|
311
|
+
monkeypatch.setattr(clustering, "HDBSCAN", _RecordingHDBSCAN)
|
|
312
|
+
|
|
313
|
+
clustering._build_clusterer(
|
|
314
|
+
"fast_hdbscan", min_cluster_size=2000, min_samples=100,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
assert recorded == {"min_cluster_size": 2000, "min_samples": 100}
|
|
318
|
+
assert "core_dist_n_jobs" not in recorded
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ── 5. Error propagation — worker raises → parent ValueError ────────────────
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_worker_error_surfaces_as_value_error(
|
|
325
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""A worker that puts an ('error', ...) tuple must surface as a
|
|
328
|
+
normal ValueError in the parent — the existing detector contract.
|
|
329
|
+
No hang, no multiprocessing traceback bleeding through."""
|
|
330
|
+
monkeypatch.setattr(
|
|
331
|
+
clustering, "_WORKER_TARGET", _erroring_worker,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
X = np.zeros((10, 2), dtype=np.float64)
|
|
335
|
+
with pytest.raises(ValueError, match="induced failure"):
|
|
336
|
+
clustering.fit_predict_interruptible(
|
|
337
|
+
X, min_cluster_size=5, min_samples=2,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── 6. Child dies without queueing — parent RuntimeError, no hang ───────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_child_dying_without_result_raises_runtime_error(
|
|
345
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""If the worker exits without putting a result on the queue
|
|
348
|
+
(segfault, OOM kill, unhandled signal), the polling rail's
|
|
349
|
+
is_alive() check must raise RuntimeError mentioning the exit code.
|
|
350
|
+
Indefinite queue.get() would have hung forever — this is the
|
|
351
|
+
regression guard for the hang.
|
|
352
|
+
|
|
353
|
+
Also asserts the universal cleanup path RAN on this abnormal-exit
|
|
354
|
+
code path. Pre-fix (Glenn CR P1): fit_predict_interruptible only
|
|
355
|
+
caught KeyboardInterrupt, so RuntimeError from _await_child_result
|
|
356
|
+
bypassed queue drain/close/join_thread AND child.close — the exact
|
|
357
|
+
multiprocessing resource leak the helper was designed to handle.
|
|
358
|
+
Spying on _drain_and_close_queue confirms the cleanup ran.
|
|
359
|
+
"""
|
|
360
|
+
monkeypatch.setattr(
|
|
361
|
+
clustering, "_WORKER_TARGET", _dying_worker,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
cleanup_calls: list = []
|
|
365
|
+
real_drain = clustering._drain_and_close_queue
|
|
366
|
+
|
|
367
|
+
def _spy_drain(result_queue):
|
|
368
|
+
cleanup_calls.append("drain")
|
|
369
|
+
return real_drain(result_queue)
|
|
370
|
+
|
|
371
|
+
monkeypatch.setattr(
|
|
372
|
+
clustering, "_drain_and_close_queue", _spy_drain,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
X = np.zeros((10, 2), dtype=np.float64)
|
|
376
|
+
start = time.monotonic()
|
|
377
|
+
with pytest.raises(RuntimeError, match=r"exitcode=7"):
|
|
378
|
+
clustering.fit_predict_interruptible(
|
|
379
|
+
X, min_cluster_size=5, min_samples=2,
|
|
380
|
+
)
|
|
381
|
+
elapsed = time.monotonic() - start
|
|
382
|
+
# If this exceeds a few seconds, the polling rail regressed (the
|
|
383
|
+
# helper waited on an indefinite queue.get instead of polling
|
|
384
|
+
# is_alive). Generous bound — startup overhead varies.
|
|
385
|
+
assert elapsed < 10.0, f"helper took {elapsed:.2f}s — polling rail regressed?"
|
|
386
|
+
# Cleanup MUST have run on the abnormal-exit path. Without this,
|
|
387
|
+
# P1 regresses: RuntimeError flies past both cleanup branches and
|
|
388
|
+
# resource_tracker leaks on exactly the path the helper exists
|
|
389
|
+
# to handle.
|
|
390
|
+
assert cleanup_calls == ["drain"], (
|
|
391
|
+
f"cleanup did not run on dead-child path; called: {cleanup_calls!r}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ── Module export sanity ────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_clustering_module_public_surface() -> None:
|
|
399
|
+
"""The new helper is exported alongside HDBSCAN and ACTIVE_BACKEND."""
|
|
400
|
+
importlib.reload(clustering)
|
|
401
|
+
assert set(clustering.__all__) == {
|
|
402
|
+
"HDBSCAN", "ACTIVE_BACKEND", "fit_predict_interruptible",
|
|
403
|
+
}
|
|
404
|
+
assert callable(clustering.fit_predict_interruptible)
|