bdo-toolkit 1.0.0__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.
Files changed (48) hide show
  1. bdo_toolkit/__init__.py +87 -0
  2. bdo_toolkit/_async_sessions.py +651 -0
  3. bdo_toolkit/_capture_backend.py +194 -0
  4. bdo_toolkit/_capture_options.py +68 -0
  5. bdo_toolkit/_capture_runtime.py +626 -0
  6. bdo_toolkit/_deposit_origin.py +1599 -0
  7. bdo_toolkit/_engine.py +327 -0
  8. bdo_toolkit/_framing.py +904 -0
  9. bdo_toolkit/_profile_runtime.py +157 -0
  10. bdo_toolkit/_protocol.py +386 -0
  11. bdo_toolkit/_reassembly.py +654 -0
  12. bdo_toolkit/_specs.py +285 -0
  13. bdo_toolkit/_storage_destination_validation.py +167 -0
  14. bdo_toolkit/_storage_hydration.py +241 -0
  15. bdo_toolkit/_version.py +3 -0
  16. bdo_toolkit/calibration.py +3223 -0
  17. bdo_toolkit/capture.py +1713 -0
  18. bdo_toolkit/character_state.py +3506 -0
  19. bdo_toolkit/cli.py +948 -0
  20. bdo_toolkit/diagnostics.py +51 -0
  21. bdo_toolkit/events.py +214 -0
  22. bdo_toolkit/filters.py +105 -0
  23. bdo_toolkit/item_state.py +48 -0
  24. bdo_toolkit/origin_learning.py +779 -0
  25. bdo_toolkit/profiles.py +370 -0
  26. bdo_toolkit/py.typed +1 -0
  27. bdo_toolkit/remote_profiles.py +358 -0
  28. bdo_toolkit/solare/__init__.py +50 -0
  29. bdo_toolkit/solare/_constants.py +94 -0
  30. bdo_toolkit/solare/_detail_learning.py +1437 -0
  31. bdo_toolkit/solare/_details.py +796 -0
  32. bdo_toolkit/solare/_discovery.py +1212 -0
  33. bdo_toolkit/solare/_live_tracker.py +472 -0
  34. bdo_toolkit/solare/_replay_capture.py +182 -0
  35. bdo_toolkit/solare/_result.py +441 -0
  36. bdo_toolkit/solare/_scanner.py +203 -0
  37. bdo_toolkit/solare/_validation.py +11 -0
  38. bdo_toolkit/solare/async_session.py +444 -0
  39. bdo_toolkit/solare/models.py +806 -0
  40. bdo_toolkit/solare/replay.py +62 -0
  41. bdo_toolkit/solare/session.py +1051 -0
  42. bdo_toolkit/writers.py +30 -0
  43. bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
  44. bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
  45. bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
  46. bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  47. bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,626 @@
1
+ """Private live packet-acquisition lifecycle shared by toolkit features.
2
+
3
+ This module deliberately stops below protocol decoding. It owns interface
4
+ selection, capture filters, Scapy's background sniffer, and the enlarged
5
+ Windows/Npcap capture socket, while callers retain control of packet queues,
6
+ decoder finalization, and app-facing result delivery.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import math
13
+ import os
14
+ import time
15
+ from dataclasses import dataclass
16
+ from threading import Event, Lock, current_thread
17
+ from typing import Any, Callable, Optional
18
+
19
+ from ._capture_backend import (
20
+ build_bpf_filter,
21
+ detect_default_capture_target,
22
+ import_scapy,
23
+ )
24
+ from ._capture_options import PacketCaptureOptions
25
+
26
+
27
+ DEFAULT_CAPTURE_BUFFER_BYTES = 64 * 1024 * 1024
28
+ DEFAULT_STARTUP_TIMEOUT_SECONDS = 10.0
29
+ _CAPTURE_JOIN_TIMEOUT_SECONDS = 2.0
30
+
31
+
32
+ def _capture_is_clean(
33
+ *,
34
+ tcp_gap_resets: int,
35
+ pcap_dropped: Optional[int],
36
+ pcap_interface_dropped: Optional[int],
37
+ packet_queue_overflows: int,
38
+ flow_state_evictions: int,
39
+ ) -> bool:
40
+ """Apply the shared acquisition-loss predicate used by public health types."""
41
+
42
+ return (
43
+ tcp_gap_resets == 0
44
+ and pcap_dropped in (None, 0)
45
+ and pcap_interface_dropped in (None, 0)
46
+ and packet_queue_overflows == 0
47
+ and flow_state_evictions == 0
48
+ )
49
+
50
+
51
+ def _attach_cleanup_owner(
52
+ error: BaseException,
53
+ owner: object,
54
+ *,
55
+ context: str,
56
+ ) -> None:
57
+ """Keep retry ownership reachable when startup/context entry cannot return."""
58
+
59
+ attached = False
60
+ try:
61
+ setattr(error, "cleanup_owner", owner)
62
+ attached = True
63
+ except BaseException:
64
+ # Built-in and ordinary application exceptions expose __dict__. Keep
65
+ # the original failure identity even for an exotic immutable subtype.
66
+ pass
67
+ if hasattr(error, "add_note"):
68
+ if attached:
69
+ error.add_note(
70
+ f"{context} cleanup is incomplete; retry stop() on "
71
+ "exception.cleanup_owner"
72
+ )
73
+ else:
74
+ error.add_note(
75
+ f"{context} cleanup is incomplete; this exception subtype "
76
+ "cannot expose cleanup_owner, so retain and stop the original "
77
+ "session object"
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class CaptureEndpoint:
83
+ """Resolved interface and filters used by one live capture."""
84
+
85
+ interface: Optional[str]
86
+ local_ip: Optional[str]
87
+ bpf_filter: Optional[str]
88
+
89
+ def to_dict(self) -> dict[str, Optional[str]]:
90
+ """Return the resolved endpoint as a JSON-ready mapping."""
91
+
92
+ return {
93
+ "interface": self.interface,
94
+ "local_ip": self.local_ip,
95
+ "bpf_filter": self.bpf_filter,
96
+ }
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class CaptureStats:
101
+ """Best-effort kernel capture statistics collected during shutdown."""
102
+
103
+ received: Optional[int] = None
104
+ dropped: Optional[int] = None
105
+ interface_dropped: Optional[int] = None
106
+ capture_buffer_bytes: Optional[int] = None
107
+
108
+
109
+ def _is_windows() -> bool:
110
+ return os.name == "nt"
111
+
112
+
113
+ def _new_async_sniffer(**options: Any) -> Any:
114
+ from scapy.sendrecv import AsyncSniffer # type: ignore
115
+
116
+ return AsyncSniffer(**options)
117
+
118
+
119
+ def _open_enlarged_windows_socket(
120
+ *,
121
+ interface: Optional[str],
122
+ bpf_filter: Optional[str],
123
+ buffer_bytes: int,
124
+ ) -> Any:
125
+ """Open one libpcap socket and enlarge its Npcap kernel buffer."""
126
+
127
+ from scapy.arch.libpcap import L2pcapListenSocket # type: ignore
128
+ from scapy.libs.winpcapy import pcap_setbuff # type: ignore
129
+
130
+ capture_socket = L2pcapListenSocket(
131
+ iface=interface,
132
+ filter=bpf_filter,
133
+ )
134
+ try:
135
+ capture_handle = capture_socket.pcap_fd.pcap
136
+ if pcap_setbuff(capture_handle, buffer_bytes) != 0:
137
+ raise RuntimeError(
138
+ f"Npcap rejected the requested {buffer_bytes}-byte capture buffer"
139
+ )
140
+ except BaseException:
141
+ capture_socket.close()
142
+ raise
143
+ return capture_socket
144
+
145
+
146
+ def _read_windows_capture_stats(
147
+ capture_socket: Any,
148
+ ) -> tuple[int, int, int] | None:
149
+ """Read Npcap counters while the caller-owned handle is still open."""
150
+
151
+ try:
152
+ from ctypes import byref
153
+
154
+ from scapy.libs.winpcapy import pcap_stat, pcap_stats # type: ignore
155
+
156
+ capture_stats = pcap_stat()
157
+ if pcap_stats(
158
+ capture_socket.pcap_fd.pcap,
159
+ byref(capture_stats),
160
+ ) != 0:
161
+ return None
162
+ return (
163
+ int(capture_stats.ps_recv),
164
+ int(capture_stats.ps_drop),
165
+ int(capture_stats.ps_ifdrop),
166
+ )
167
+ except BaseException:
168
+ # Statistics are diagnostic evidence. Their absence must not turn an
169
+ # otherwise valid capture into a decoder failure.
170
+ return None
171
+
172
+
173
+ class LivePacketCapture:
174
+ """Single-use private owner of one passive Scapy capture handle.
175
+
176
+ ``on_packet`` runs in Scapy's capture thread. Callers that need a tiny
177
+ capture callback (notably Solare's multi-megabyte leaderboard burst) should
178
+ pass a queue's non-blocking ``put`` method and process packets elsewhere.
179
+ Existing synchronous decoders can keep their present callback semantics.
180
+
181
+ The enlarged Npcap buffer is best-effort by default so existing toolkit
182
+ features do not acquire a dependency on Scapy's private Windows bindings.
183
+ Integrity-sensitive classifiers can set ``require_capture_buffer=True``.
184
+ That requirement applies only on Windows, where the Npcap buffer exists.
185
+ """
186
+
187
+ def __init__(
188
+ self,
189
+ *,
190
+ capture_options: PacketCaptureOptions,
191
+ on_packet: Callable[[object], None],
192
+ capture_buffer_bytes: int = DEFAULT_CAPTURE_BUFFER_BYTES,
193
+ require_capture_buffer: bool = False,
194
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT_SECONDS,
195
+ ) -> None:
196
+ if not isinstance(capture_options, PacketCaptureOptions):
197
+ raise TypeError("capture_options must be a PacketCaptureOptions")
198
+ if not callable(on_packet):
199
+ raise TypeError("on_packet must be callable")
200
+ if (
201
+ isinstance(capture_buffer_bytes, bool)
202
+ or not isinstance(capture_buffer_bytes, int)
203
+ or capture_buffer_bytes <= 0
204
+ ):
205
+ raise ValueError("capture_buffer_bytes must be a positive integer")
206
+ if not isinstance(require_capture_buffer, bool):
207
+ raise ValueError("require_capture_buffer must be a boolean")
208
+ if (
209
+ isinstance(startup_timeout, bool)
210
+ or not isinstance(startup_timeout, (int, float))
211
+ or not math.isfinite(startup_timeout)
212
+ or startup_timeout <= 0
213
+ ):
214
+ raise ValueError("startup_timeout must be finite and greater than zero")
215
+
216
+ self._capture_options = capture_options
217
+ self._on_packet = on_packet
218
+ self._capture_buffer_bytes = capture_buffer_bytes
219
+ self._require_capture_buffer = require_capture_buffer
220
+ self._startup_timeout = float(startup_timeout)
221
+
222
+ self._capture_ready = Event()
223
+ self._state_lock = Lock()
224
+ self._cleanup_lock = Lock()
225
+ self._start_attempted = False
226
+ self._started = False
227
+ self._stopped = False
228
+ self._capture: Any = None
229
+ self._capture_socket: Any = None
230
+ self._endpoint: Optional[CaptureEndpoint] = None
231
+ self._stats = CaptureStats()
232
+ self._error: Optional[BaseException] = None
233
+ self._buffer_error: Optional[BaseException] = None
234
+ self._cleanup_incomplete = False
235
+ self._cleanup_error: Optional[BaseException] = None
236
+
237
+ @property
238
+ def endpoint(self) -> Optional[CaptureEndpoint]:
239
+ """Resolved endpoint after capture startup has been attempted."""
240
+
241
+ return self._endpoint
242
+
243
+ @property
244
+ def stats(self) -> CaptureStats:
245
+ """Latest capture counters; kernel values appear after ``stop()``."""
246
+
247
+ return self._stats
248
+
249
+ def snapshot_stats(self) -> CaptureStats:
250
+ """Read best-effort counters without stopping the capture.
251
+
252
+ This is used to bind acquisition health to a decoder's first latched
253
+ result even when the caller intentionally keeps recording afterward.
254
+ Backends without a caller-owned statistics handle return the counters
255
+ that are currently available.
256
+ """
257
+
258
+ with self._cleanup_lock:
259
+ if not self._started:
260
+ raise RuntimeError("live packet capture was not started")
261
+ if self._stopped:
262
+ return self._stats
263
+ received = dropped = interface_dropped = None
264
+ if self._capture_socket is not None:
265
+ raw_stats = _read_windows_capture_stats(self._capture_socket)
266
+ if raw_stats is not None:
267
+ received, dropped, interface_dropped = raw_stats
268
+ return CaptureStats(
269
+ received=received,
270
+ dropped=dropped,
271
+ interface_dropped=interface_dropped,
272
+ capture_buffer_bytes=self._stats.capture_buffer_bytes,
273
+ )
274
+
275
+ @property
276
+ def buffer_error(self) -> Optional[BaseException]:
277
+ """Why best-effort enlarged-buffer setup fell back, if it did."""
278
+
279
+ return self._buffer_error
280
+
281
+ @property
282
+ def error(self) -> Optional[BaseException]:
283
+ """First callback, sniffer, or shutdown failure."""
284
+
285
+ with self._state_lock:
286
+ error = self._error
287
+ if error is not None:
288
+ return error
289
+ capture_error = getattr(self._capture, "exception", None)
290
+ return capture_error if isinstance(capture_error, BaseException) else None
291
+
292
+ @property
293
+ def cleanup_incomplete(self) -> bool:
294
+ """Whether shutdown still owns resources that require another attempt."""
295
+
296
+ return self._cleanup_incomplete
297
+
298
+ @property
299
+ def cleanup_error(self) -> Optional[BaseException]:
300
+ """Explicit failure explaining why the last shutdown was incomplete."""
301
+
302
+ return self._cleanup_error
303
+
304
+ @property
305
+ def running(self) -> bool:
306
+ capture = self._capture
307
+ capture_thread = getattr(capture, "thread", None)
308
+ thread_finished = (
309
+ capture_thread is not None
310
+ and capture_thread.ident is not None
311
+ and not capture_thread.is_alive()
312
+ )
313
+ return (
314
+ (self._started or self._cleanup_incomplete)
315
+ and not self._stopped
316
+ and capture is not None
317
+ and not thread_finished
318
+ and (not self._capture_ready.is_set() or bool(capture.running))
319
+ )
320
+
321
+ @property
322
+ def stopped(self) -> bool:
323
+ return self._stopped
324
+
325
+ def start(self) -> None:
326
+ """Resolve the endpoint, open the adapter, and wait until it is ready."""
327
+
328
+ with self._cleanup_lock:
329
+ if self._start_attempted:
330
+ raise RuntimeError("live packet capture is single-use")
331
+ self._start_attempted = True
332
+ self._capture_ready.clear()
333
+
334
+ # Scapy lazily initializes its Windows interface table. Import it
335
+ # before route detection so the selected interface is reliable.
336
+ IP, TCP, _, _, _ = import_scapy()
337
+
338
+ detected_target = None
339
+ if self._capture_options.interface is None:
340
+ detected_target = detect_default_capture_target()
341
+ capture_interface = detected_target.interface
342
+ else:
343
+ capture_interface = self._capture_options.interface
344
+
345
+ capture_local_ip = self._capture_options.local_ip
346
+ if (
347
+ capture_local_ip is None
348
+ and self._capture_options.interface is None
349
+ and self._capture_options.auto_local_ip
350
+ ):
351
+ assert detected_target is not None
352
+ capture_local_ip = detected_target.local_ip
353
+
354
+ bpf_filter = (
355
+ build_bpf_filter(self._capture_options.ports, capture_local_ip)
356
+ if self._capture_options.use_bpf
357
+ else None
358
+ )
359
+ lfilter = None
360
+ if not self._capture_options.use_bpf:
361
+ ports = frozenset(self._capture_options.ports)
362
+
363
+ def lfilter(packet: object) -> bool:
364
+ return bool(
365
+ IP in packet # type: ignore[operator]
366
+ and TCP in packet # type: ignore[operator]
367
+ and int(packet[TCP].sport) in ports # type: ignore[index]
368
+ and (
369
+ capture_local_ip is None
370
+ or str(packet[IP].dst) == capture_local_ip # type: ignore[index]
371
+ )
372
+ )
373
+
374
+ self._endpoint = CaptureEndpoint(
375
+ interface=capture_interface,
376
+ local_ip=capture_local_ip,
377
+ bpf_filter=bpf_filter,
378
+ )
379
+
380
+ capture_socket = None
381
+ configured_buffer_bytes = None
382
+ if _is_windows():
383
+ try:
384
+ capture_socket = _open_enlarged_windows_socket(
385
+ interface=capture_interface,
386
+ bpf_filter=bpf_filter,
387
+ buffer_bytes=self._capture_buffer_bytes,
388
+ )
389
+ configured_buffer_bytes = self._capture_buffer_bytes
390
+ except BaseException as exc:
391
+ self._buffer_error = exc
392
+ if self._require_capture_buffer:
393
+ self._record_error(exc)
394
+ raise RuntimeError(
395
+ "failed to configure the enlarged Npcap capture buffer"
396
+ ) from exc
397
+
398
+ self._stats = CaptureStats(
399
+ capture_buffer_bytes=configured_buffer_bytes,
400
+ )
401
+
402
+ def handle_packet(packet: object) -> None:
403
+ try:
404
+ self._on_packet(packet)
405
+ except BaseException as exc:
406
+ self._record_error(exc)
407
+ # Scapy intentionally ends a capture whose callback raises.
408
+ # Retaining the error here lets the owning session surface
409
+ # the original decoder or queue failure.
410
+ raise
411
+
412
+ sniffer_options: dict[str, Any] = {
413
+ "lfilter": lfilter,
414
+ "prn": handle_packet,
415
+ "store": False,
416
+ "started_callback": self._capture_ready.set,
417
+ }
418
+ if capture_socket is not None:
419
+ # AsyncSniffer must use exactly this enlarged socket. Supplying
420
+ # iface/filter too would make Scapy open another default-sized
421
+ # handle and silently bypass it.
422
+ sniffer_options["opened_socket"] = capture_socket
423
+ else:
424
+ sniffer_options["iface"] = capture_interface
425
+ sniffer_options["filter"] = bpf_filter
426
+
427
+ capture = None
428
+ self._capture_socket = capture_socket
429
+ try:
430
+ capture = _new_async_sniffer(**sniffer_options)
431
+ self._capture = capture
432
+ capture.start()
433
+ deadline = time.monotonic() + self._startup_timeout
434
+ while not self._capture_ready.wait(timeout=0.05):
435
+ capture_error = getattr(capture, "exception", None)
436
+ if isinstance(capture_error, BaseException):
437
+ raise capture_error
438
+ capture_thread = getattr(capture, "thread", None)
439
+ if (
440
+ capture_thread is not None
441
+ and capture_thread.ident is not None
442
+ and not capture_thread.is_alive()
443
+ ):
444
+ raise RuntimeError(
445
+ "live capture thread ended during startup"
446
+ )
447
+ if time.monotonic() >= deadline:
448
+ raise RuntimeError(
449
+ "timed out while opening the live capture interface"
450
+ )
451
+ except BaseException as exc:
452
+ self._record_error(exc)
453
+ try:
454
+ self._shutdown_backend()
455
+ except BaseException as cleanup_error:
456
+ # Startup remains the primary failure, but incomplete
457
+ # cleanup must be explicit in its traceback and remains
458
+ # retryable through stop().
459
+ if hasattr(exc, "add_note"):
460
+ exc.add_note(
461
+ "live capture startup cleanup also failed: "
462
+ f"{cleanup_error!r}"
463
+ )
464
+ if not self._cleanup_incomplete:
465
+ self._capture = None
466
+ self._capture_socket = None
467
+ else:
468
+ _attach_cleanup_owner(
469
+ exc,
470
+ self,
471
+ context="live packet capture startup",
472
+ )
473
+ raise
474
+
475
+ self._started = True
476
+
477
+ def stop(self) -> CaptureStats:
478
+ """Stop and join capture, read counters, then close the owned socket."""
479
+
480
+ with self._cleanup_lock:
481
+ if not self._started and not self._cleanup_incomplete:
482
+ raise RuntimeError("live packet capture was not started")
483
+ if self._stopped:
484
+ return self._stats
485
+ self._shutdown_backend()
486
+ return self._stats
487
+
488
+ def _shutdown_backend(self) -> None:
489
+ """Attempt every safe shutdown step and verify resource ownership.
490
+
491
+ Scapy's ordinary ``AsyncSniffer.stop()`` joins without a timeout. Ask
492
+ it only to signal the backend, then perform our own bounded join. A
493
+ caller-owned Npcap socket is closed before that join so a blocking read
494
+ has an independent way to wake even when the stop request raises.
495
+ """
496
+
497
+ capture = self._capture
498
+ capture_socket = self._capture_socket
499
+ failures: list[BaseException] = []
500
+
501
+ def retain(error: BaseException) -> None:
502
+ self._record_error(error)
503
+ if not any(error is previous for previous in failures):
504
+ failures.append(error)
505
+
506
+ capture_error = getattr(capture, "exception", None)
507
+ if isinstance(capture_error, BaseException):
508
+ self._record_error(capture_error)
509
+
510
+ capture_thread = getattr(capture, "thread", None)
511
+ thread_alive = False
512
+ if capture_thread is not None:
513
+ thread_alive = bool(capture_thread.is_alive())
514
+
515
+ if capture is not None and (
516
+ bool(getattr(capture, "running", False)) or thread_alive
517
+ ):
518
+ try:
519
+ stop_method = capture.stop
520
+ try:
521
+ parameters = inspect.signature(stop_method).parameters.values()
522
+ except (TypeError, ValueError):
523
+ # The real supported Scapy backend accepts ``join``. This
524
+ # fallback is for non-introspectable compatible wrappers.
525
+ stop_method(join=False)
526
+ else:
527
+ supports_join = any(
528
+ parameter.name == "join"
529
+ or parameter.kind is inspect.Parameter.VAR_KEYWORD
530
+ for parameter in parameters
531
+ )
532
+ if supports_join:
533
+ stop_method(join=False)
534
+ else:
535
+ # Lightweight test/application fakes may expose only a
536
+ # synchronous no-argument stop method.
537
+ stop_method()
538
+ except BaseException as exc:
539
+ retain(exc)
540
+
541
+ received = self._stats.received
542
+ dropped = self._stats.dropped
543
+ interface_dropped = self._stats.interface_dropped
544
+ if capture_socket is not None:
545
+ raw_stats = _read_windows_capture_stats(capture_socket)
546
+ if raw_stats is not None:
547
+ received, dropped, interface_dropped = raw_stats
548
+ try:
549
+ capture_socket.close()
550
+ except BaseException as exc:
551
+ # Keep the socket reference so a later stop() can retry it.
552
+ retain(exc)
553
+ else:
554
+ self._capture_socket = None
555
+
556
+ self._stats = CaptureStats(
557
+ received=received,
558
+ dropped=dropped,
559
+ interface_dropped=interface_dropped,
560
+ capture_buffer_bytes=self._stats.capture_buffer_bytes,
561
+ )
562
+
563
+ if capture_thread is not None:
564
+ thread_alive = bool(capture_thread.is_alive())
565
+ if thread_alive:
566
+ if capture_thread is current_thread():
567
+ retain(
568
+ RuntimeError(
569
+ "live capture cannot join its own capture thread"
570
+ )
571
+ )
572
+ else:
573
+ try:
574
+ capture_thread.join(
575
+ timeout=_CAPTURE_JOIN_TIMEOUT_SECONDS
576
+ )
577
+ except BaseException as exc:
578
+ retain(exc)
579
+ thread_alive = bool(capture_thread.is_alive())
580
+
581
+ backend_running_without_thread = (
582
+ capture is not None
583
+ and capture_thread is None
584
+ and bool(getattr(capture, "running", False))
585
+ )
586
+ incomplete = (
587
+ thread_alive
588
+ or backend_running_without_thread
589
+ or self._capture_socket is not None
590
+ )
591
+ self._cleanup_incomplete = incomplete
592
+ self._stopped = not incomplete
593
+
594
+ if incomplete:
595
+ reasons: list[str] = []
596
+ if thread_alive or backend_running_without_thread:
597
+ reasons.append(
598
+ "the capture thread/backend is still running after the "
599
+ f"{_CAPTURE_JOIN_TIMEOUT_SECONDS:g}-second join deadline"
600
+ )
601
+ if self._capture_socket is not None:
602
+ reasons.append("the caller-owned capture socket did not close")
603
+ cleanup_error = RuntimeError(
604
+ "live packet capture cleanup is incomplete: " + "; ".join(reasons)
605
+ )
606
+ self._cleanup_error = cleanup_error
607
+ self._record_error(cleanup_error)
608
+ if failures:
609
+ raise cleanup_error from failures[0]
610
+ raise cleanup_error
611
+
612
+ self._cleanup_error = None
613
+ if failures:
614
+ raise failures[0]
615
+
616
+ def raise_if_failed(self) -> None:
617
+ """Raise the first retained capture failure, if one occurred."""
618
+
619
+ error = self.error
620
+ if error is not None:
621
+ raise error
622
+
623
+ def _record_error(self, error: BaseException) -> None:
624
+ with self._state_lock:
625
+ if self._error is None:
626
+ self._error = error