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.
- bdo_toolkit/__init__.py +87 -0
- bdo_toolkit/_async_sessions.py +651 -0
- bdo_toolkit/_capture_backend.py +194 -0
- bdo_toolkit/_capture_options.py +68 -0
- bdo_toolkit/_capture_runtime.py +626 -0
- bdo_toolkit/_deposit_origin.py +1599 -0
- bdo_toolkit/_engine.py +327 -0
- bdo_toolkit/_framing.py +904 -0
- bdo_toolkit/_profile_runtime.py +157 -0
- bdo_toolkit/_protocol.py +386 -0
- bdo_toolkit/_reassembly.py +654 -0
- bdo_toolkit/_specs.py +285 -0
- bdo_toolkit/_storage_destination_validation.py +167 -0
- bdo_toolkit/_storage_hydration.py +241 -0
- bdo_toolkit/_version.py +3 -0
- bdo_toolkit/calibration.py +3223 -0
- bdo_toolkit/capture.py +1713 -0
- bdo_toolkit/character_state.py +3506 -0
- bdo_toolkit/cli.py +948 -0
- bdo_toolkit/diagnostics.py +51 -0
- bdo_toolkit/events.py +214 -0
- bdo_toolkit/filters.py +105 -0
- bdo_toolkit/item_state.py +48 -0
- bdo_toolkit/origin_learning.py +779 -0
- bdo_toolkit/profiles.py +370 -0
- bdo_toolkit/py.typed +1 -0
- bdo_toolkit/remote_profiles.py +358 -0
- bdo_toolkit/solare/__init__.py +50 -0
- bdo_toolkit/solare/_constants.py +94 -0
- bdo_toolkit/solare/_detail_learning.py +1437 -0
- bdo_toolkit/solare/_details.py +796 -0
- bdo_toolkit/solare/_discovery.py +1212 -0
- bdo_toolkit/solare/_live_tracker.py +472 -0
- bdo_toolkit/solare/_replay_capture.py +182 -0
- bdo_toolkit/solare/_result.py +441 -0
- bdo_toolkit/solare/_scanner.py +203 -0
- bdo_toolkit/solare/_validation.py +11 -0
- bdo_toolkit/solare/async_session.py +444 -0
- bdo_toolkit/solare/models.py +806 -0
- bdo_toolkit/solare/replay.py +62 -0
- bdo_toolkit/solare/session.py +1051 -0
- bdo_toolkit/writers.py +30 -0
- bdo_toolkit-1.0.0.dist-info/METADATA +143 -0
- bdo_toolkit-1.0.0.dist-info/RECORD +48 -0
- bdo_toolkit-1.0.0.dist-info/WHEEL +5 -0
- bdo_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- bdo_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- bdo_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
"""Controlled passive live capture for Arena of Solare snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from queue import Empty, Full, Queue
|
|
12
|
+
from threading import Event, Lock, Thread, current_thread
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from bdo_toolkit._capture_backend import make_packet_handler
|
|
17
|
+
from bdo_toolkit._capture_options import PacketCaptureOptions
|
|
18
|
+
from bdo_toolkit._capture_runtime import (
|
|
19
|
+
CaptureStats,
|
|
20
|
+
LivePacketCapture,
|
|
21
|
+
_attach_cleanup_owner,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from ._constants import (
|
|
25
|
+
LIVE_CAPTURE_BUFFER_BYTES,
|
|
26
|
+
LIVE_PACKET_QUEUE_MAX,
|
|
27
|
+
LIVE_UPDATE_QUEUE_MAX,
|
|
28
|
+
SOLARE_DEFAULT_CAPTURE_SECONDS,
|
|
29
|
+
)
|
|
30
|
+
from ._live_tracker import LiveSolareDiscoveryTracker
|
|
31
|
+
from ._replay_capture import SolareFrameCollector
|
|
32
|
+
from ._result import _resource_loss_summary, build_solare_result
|
|
33
|
+
from ._validation import validate_retain_raw_extensions
|
|
34
|
+
from .models import (
|
|
35
|
+
SolareCaptureEndpoint,
|
|
36
|
+
SolareCaptureHealth,
|
|
37
|
+
SolareCaptureResult,
|
|
38
|
+
SolareUpdate,
|
|
39
|
+
SolareUpdateKind,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_POLL_INTERVAL_SECONDS = 0.2
|
|
44
|
+
_ACTIVE_UPDATE_SESSIONS: ContextVar[tuple[object, ...]] = ContextVar(
|
|
45
|
+
"bdo_toolkit_active_solare_update_sessions",
|
|
46
|
+
default=(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_timeout(value: Optional[float], *, name: str) -> None:
|
|
51
|
+
if value is None:
|
|
52
|
+
return
|
|
53
|
+
if (
|
|
54
|
+
isinstance(value, bool)
|
|
55
|
+
or not isinstance(value, (int, float))
|
|
56
|
+
or not math.isfinite(value)
|
|
57
|
+
or value < 0
|
|
58
|
+
):
|
|
59
|
+
raise ValueError(f"{name} must be finite and non-negative")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LiveSolareSession:
|
|
63
|
+
"""Single-use live session that assembles one structural snapshot.
|
|
64
|
+
|
|
65
|
+
Packet acquisition runs in Scapy's thread, while pcap writing, TCP
|
|
66
|
+
reassembly, discovery, and detailed decoding run in a separate worker.
|
|
67
|
+
This keeps the capture callback small enough for the multi-megabyte
|
|
68
|
+
leaderboard burst. Structural discovery receives no known Solare opcode or
|
|
69
|
+
saved layout; validated detail fingerprints are considered only after the
|
|
70
|
+
ranking tables independently confirm.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Unknown Solare detail geometries are learned only after the complete
|
|
74
|
+
# snapshot has been structurally confirmed. That bounded, fail-closed
|
|
75
|
+
# finalization can legitimately take several seconds on the full 720
|
|
76
|
+
# records, so leave headroom beyond the historical five-second decoder
|
|
77
|
+
# cleanup deadline while still bounding a genuinely stuck worker.
|
|
78
|
+
_WORKER_STOP_TIMEOUT_SECONDS = 15.0
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
84
|
+
save_pcap: str | Path | None = None,
|
|
85
|
+
stop_on_complete: bool = True,
|
|
86
|
+
retain_raw_extensions: bool = False,
|
|
87
|
+
on_update: Optional[Callable[[SolareUpdate], None]] = None,
|
|
88
|
+
capture_buffer_bytes: int = LIVE_CAPTURE_BUFFER_BYTES,
|
|
89
|
+
) -> None:
|
|
90
|
+
if capture_options is not None and not isinstance(
|
|
91
|
+
capture_options, PacketCaptureOptions
|
|
92
|
+
):
|
|
93
|
+
raise TypeError("capture_options must be a PacketCaptureOptions or None")
|
|
94
|
+
if not isinstance(stop_on_complete, bool):
|
|
95
|
+
raise TypeError("stop_on_complete must be a boolean")
|
|
96
|
+
retain_raw_extensions = validate_retain_raw_extensions(
|
|
97
|
+
retain_raw_extensions
|
|
98
|
+
)
|
|
99
|
+
if on_update is not None and not callable(on_update):
|
|
100
|
+
raise TypeError("on_update must be callable or None")
|
|
101
|
+
|
|
102
|
+
self._capture_options = capture_options or PacketCaptureOptions()
|
|
103
|
+
self._save_pcap = Path(save_pcap) if save_pcap is not None else None
|
|
104
|
+
self._stop_on_complete = stop_on_complete
|
|
105
|
+
self._retain_raw_extensions = retain_raw_extensions
|
|
106
|
+
self._on_update = on_update
|
|
107
|
+
self._capture_buffer_bytes = capture_buffer_bytes
|
|
108
|
+
|
|
109
|
+
self._packet_queue: Queue[object] = Queue(maxsize=LIVE_PACKET_QUEUE_MAX)
|
|
110
|
+
self._update_queue: Queue[SolareUpdate] = Queue(
|
|
111
|
+
maxsize=LIVE_UPDATE_QUEUE_MAX
|
|
112
|
+
)
|
|
113
|
+
self._stop_requested = Event()
|
|
114
|
+
self._stopped = Event()
|
|
115
|
+
self._worker_ready = Event()
|
|
116
|
+
self._state_lock = Lock()
|
|
117
|
+
self._finish_lock = Lock()
|
|
118
|
+
|
|
119
|
+
self._start_attempted = False
|
|
120
|
+
self._started = False
|
|
121
|
+
self._capture: Optional[LivePacketCapture] = None
|
|
122
|
+
self._collector: Optional[SolareFrameCollector] = None
|
|
123
|
+
self._tracker: Optional[LiveSolareDiscoveryTracker] = None
|
|
124
|
+
self._worker: Optional[Thread] = None
|
|
125
|
+
self._packet_handler: Optional[Callable[[object], None]] = None
|
|
126
|
+
self._writer: Any = None
|
|
127
|
+
self._saved_packets = 0
|
|
128
|
+
self._traffic_announced = False
|
|
129
|
+
self._tcp_gap_warning_announced = False
|
|
130
|
+
self._confirmed_health: Optional[SolareCaptureHealth] = None
|
|
131
|
+
self._result: Optional[SolareCaptureResult] = None
|
|
132
|
+
self._error: Optional[BaseException] = None
|
|
133
|
+
self._stop_reason: Optional[str] = None
|
|
134
|
+
self._endpoint: Optional[SolareCaptureEndpoint] = None
|
|
135
|
+
self._packet_queue_peak = 0
|
|
136
|
+
self._packet_queue_overflows = 0
|
|
137
|
+
self._decoder_failed = False
|
|
138
|
+
self._cleanup_incomplete = False
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def running(self) -> bool:
|
|
142
|
+
return self._started and not self._stopped.is_set()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def stopped(self) -> bool:
|
|
146
|
+
return self._stopped.is_set()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def cleanup_incomplete(self) -> bool:
|
|
150
|
+
"""Whether capture cleanup remains owned and retryable."""
|
|
151
|
+
|
|
152
|
+
return self._cleanup_incomplete
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def result(self) -> Optional[SolareCaptureResult]:
|
|
156
|
+
"""The final result after manual or automatic shutdown."""
|
|
157
|
+
|
|
158
|
+
with self._state_lock:
|
|
159
|
+
return self._result
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def error(self) -> Optional[BaseException]:
|
|
163
|
+
with self._state_lock:
|
|
164
|
+
return self._error
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def stop_reason(self) -> Optional[str]:
|
|
168
|
+
with self._state_lock:
|
|
169
|
+
return self._stop_reason
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def endpoint(self) -> Optional[SolareCaptureEndpoint]:
|
|
173
|
+
"""Resolved live-capture target, exposed as a Solare public model."""
|
|
174
|
+
|
|
175
|
+
return self._endpoint
|
|
176
|
+
|
|
177
|
+
def start(self) -> None:
|
|
178
|
+
"""Open live capture and return once the adapter is ready."""
|
|
179
|
+
|
|
180
|
+
# Final progress callbacks run while the worker owns ``_finish_lock``.
|
|
181
|
+
# A same-session recursive start is always invalid, so reject the
|
|
182
|
+
# already-attempted state before touching that lock instead of letting
|
|
183
|
+
# callback misuse deadlock finalization. The check inside the lock
|
|
184
|
+
# remains authoritative for concurrent first-start attempts.
|
|
185
|
+
if self._start_attempted:
|
|
186
|
+
raise RuntimeError("live Solare session was already started")
|
|
187
|
+
ready_update: Optional[SolareUpdate] = None
|
|
188
|
+
with self._finish_lock:
|
|
189
|
+
if self._start_attempted:
|
|
190
|
+
raise RuntimeError("live Solare session was already started")
|
|
191
|
+
self._start_attempted = True
|
|
192
|
+
capture: Optional[LivePacketCapture] = None
|
|
193
|
+
try:
|
|
194
|
+
save_path = self._prepare_save_path()
|
|
195
|
+
tracker = LiveSolareDiscoveryTracker(self._emit)
|
|
196
|
+
collector = SolareFrameCollector(
|
|
197
|
+
self._capture_options.ports,
|
|
198
|
+
on_frame=tracker.observe,
|
|
199
|
+
on_traffic=self._announce_traffic,
|
|
200
|
+
retain_frames=False,
|
|
201
|
+
)
|
|
202
|
+
capture = LivePacketCapture(
|
|
203
|
+
capture_options=self._capture_options,
|
|
204
|
+
on_packet=self._enqueue_packet,
|
|
205
|
+
capture_buffer_bytes=self._capture_buffer_bytes,
|
|
206
|
+
require_capture_buffer=True,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._tracker = tracker
|
|
210
|
+
self._collector = collector
|
|
211
|
+
self._capture = capture
|
|
212
|
+
self._packet_handler = make_packet_handler(collector)
|
|
213
|
+
|
|
214
|
+
capture.start()
|
|
215
|
+
if save_path is not None:
|
|
216
|
+
self._writer = self._open_writer(save_path)
|
|
217
|
+
|
|
218
|
+
endpoint = capture.endpoint
|
|
219
|
+
if endpoint is not None:
|
|
220
|
+
self._endpoint = SolareCaptureEndpoint(
|
|
221
|
+
interface=endpoint.interface,
|
|
222
|
+
local_ip=endpoint.local_ip,
|
|
223
|
+
bpf_filter=endpoint.bpf_filter,
|
|
224
|
+
)
|
|
225
|
+
endpoint_text = (
|
|
226
|
+
endpoint.interface if endpoint is not None else None
|
|
227
|
+
) or "Scapy default"
|
|
228
|
+
endpoint_details = ""
|
|
229
|
+
if endpoint is not None:
|
|
230
|
+
endpoint_details = (
|
|
231
|
+
f", local IP {endpoint.local_ip or 'any'}, "
|
|
232
|
+
f"filter {endpoint.bpf_filter or 'Python lfilter'}"
|
|
233
|
+
)
|
|
234
|
+
recording = (
|
|
235
|
+
f", recording {save_path}" if save_path is not None else ""
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
worker = Thread(
|
|
239
|
+
target=self._run_worker_when_ready,
|
|
240
|
+
name="bdo-toolkit-solare",
|
|
241
|
+
daemon=True,
|
|
242
|
+
)
|
|
243
|
+
self._worker = worker
|
|
244
|
+
worker.start()
|
|
245
|
+
self._started = True
|
|
246
|
+
ready_update = SolareUpdate(
|
|
247
|
+
kind=SolareUpdateKind.CAPTURE_READY,
|
|
248
|
+
message=(
|
|
249
|
+
f"passive Solare capture is ready on {endpoint_text}"
|
|
250
|
+
f"{endpoint_details}{recording}; "
|
|
251
|
+
"open the Leaderboard tab for its first load after "
|
|
252
|
+
"restarting the game"
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
self._queue_update(ready_update)
|
|
256
|
+
except BaseException as exc:
|
|
257
|
+
self._worker_ready.set()
|
|
258
|
+
self._record_error(exc)
|
|
259
|
+
if self._writer is not None:
|
|
260
|
+
try:
|
|
261
|
+
self._writer.close()
|
|
262
|
+
except BaseException as cleanup_error:
|
|
263
|
+
self._record_error(cleanup_error)
|
|
264
|
+
self._writer = None
|
|
265
|
+
if capture is not None and not capture.stopped:
|
|
266
|
+
try:
|
|
267
|
+
capture.stop()
|
|
268
|
+
except BaseException as cleanup_error:
|
|
269
|
+
self._record_error(cleanup_error)
|
|
270
|
+
if capture is not None and not capture.stopped:
|
|
271
|
+
# The native callback still owns this session through
|
|
272
|
+
# _enqueue_packet(). Reject new packets, but retain every
|
|
273
|
+
# capture/decoder owner so stop() can retry cleanup.
|
|
274
|
+
self._cleanup_incomplete = True
|
|
275
|
+
self._started = True
|
|
276
|
+
self._stop_requested.set()
|
|
277
|
+
with self._state_lock:
|
|
278
|
+
self._stop_reason = "error"
|
|
279
|
+
_attach_cleanup_owner(
|
|
280
|
+
exc,
|
|
281
|
+
self,
|
|
282
|
+
context="live Solare startup",
|
|
283
|
+
)
|
|
284
|
+
raise
|
|
285
|
+
while True:
|
|
286
|
+
try:
|
|
287
|
+
self._packet_queue.get_nowait()
|
|
288
|
+
except Empty:
|
|
289
|
+
break
|
|
290
|
+
self._tracker = None
|
|
291
|
+
self._collector = None
|
|
292
|
+
self._capture = None
|
|
293
|
+
self._packet_handler = None
|
|
294
|
+
self._worker = None
|
|
295
|
+
with self._state_lock:
|
|
296
|
+
self._stop_reason = "error"
|
|
297
|
+
self._stopped.set()
|
|
298
|
+
raise
|
|
299
|
+
|
|
300
|
+
assert ready_update is not None
|
|
301
|
+
try:
|
|
302
|
+
self._notify_update(ready_update)
|
|
303
|
+
finally:
|
|
304
|
+
self._worker_ready.set()
|
|
305
|
+
|
|
306
|
+
def _run_worker_when_ready(self) -> None:
|
|
307
|
+
self._worker_ready.wait()
|
|
308
|
+
self._run_worker()
|
|
309
|
+
|
|
310
|
+
def stop(self) -> SolareCaptureResult:
|
|
311
|
+
"""Stop capture, drain every queued packet, and return the best result."""
|
|
312
|
+
|
|
313
|
+
self._require_started()
|
|
314
|
+
if not self._stopped.is_set():
|
|
315
|
+
if self._inside_update_callback():
|
|
316
|
+
raise RuntimeError(
|
|
317
|
+
"stop() cannot block inside on_update; use "
|
|
318
|
+
"request_stop() instead"
|
|
319
|
+
)
|
|
320
|
+
self.request_stop()
|
|
321
|
+
self._worker_ready.set()
|
|
322
|
+
worker = self._worker
|
|
323
|
+
if (
|
|
324
|
+
worker is not None
|
|
325
|
+
and worker is not current_thread()
|
|
326
|
+
and worker.is_alive()
|
|
327
|
+
):
|
|
328
|
+
worker.join(timeout=self._WORKER_STOP_TIMEOUT_SECONDS)
|
|
329
|
+
if worker.is_alive():
|
|
330
|
+
cleanup_error = RuntimeError(
|
|
331
|
+
"live Solare worker cleanup is incomplete after the "
|
|
332
|
+
f"{self._WORKER_STOP_TIMEOUT_SECONDS:g}-second deadline"
|
|
333
|
+
)
|
|
334
|
+
self._record_error(cleanup_error)
|
|
335
|
+
self._cleanup_incomplete = True
|
|
336
|
+
capture = self._capture
|
|
337
|
+
if capture is not None and not capture.stopped:
|
|
338
|
+
try:
|
|
339
|
+
capture.stop()
|
|
340
|
+
except BaseException as exc:
|
|
341
|
+
self._record_error(exc)
|
|
342
|
+
# The worker may still own tracker/collector callbacks.
|
|
343
|
+
# Never finalize or release those dependencies in parallel.
|
|
344
|
+
raise cleanup_error
|
|
345
|
+
if not self._stopped.is_set():
|
|
346
|
+
self._finish_worker(self.stop_reason or "requested")
|
|
347
|
+
self.raise_if_failed()
|
|
348
|
+
result = self.result
|
|
349
|
+
if result is None:
|
|
350
|
+
raise RuntimeError("live Solare session stopped without a result")
|
|
351
|
+
return result
|
|
352
|
+
|
|
353
|
+
def request_stop(self) -> None:
|
|
354
|
+
"""Request orderly shutdown without waiting for finalization.
|
|
355
|
+
|
|
356
|
+
This is the callback-safe control method. The terminal result remains
|
|
357
|
+
available through :meth:`wait`, :meth:`stop`, or the ``finished``
|
|
358
|
+
update after the callback returns.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
self._require_started()
|
|
362
|
+
if self._stopped.is_set() or self.result is not None:
|
|
363
|
+
return
|
|
364
|
+
self._set_requested_reason("requested")
|
|
365
|
+
self._stop_requested.set()
|
|
366
|
+
|
|
367
|
+
def wait(self, timeout: Optional[float] = None) -> Optional[SolareCaptureResult]:
|
|
368
|
+
"""Wait for automatic/manual completion, returning ``None`` on timeout."""
|
|
369
|
+
|
|
370
|
+
self._require_started()
|
|
371
|
+
_validate_timeout(timeout, name="timeout")
|
|
372
|
+
if self._inside_update_callback() and not self._stopped.is_set():
|
|
373
|
+
result = self.result
|
|
374
|
+
if result is not None:
|
|
375
|
+
self.raise_if_failed()
|
|
376
|
+
return result
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
"wait() cannot block inside on_update; use request_stop() "
|
|
379
|
+
"and wait after the callback returns"
|
|
380
|
+
)
|
|
381
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
382
|
+
while not self._stopped.is_set():
|
|
383
|
+
if self._cleanup_incomplete:
|
|
384
|
+
self.raise_if_failed()
|
|
385
|
+
if deadline is None:
|
|
386
|
+
wait_seconds = _POLL_INTERVAL_SECONDS
|
|
387
|
+
else:
|
|
388
|
+
remaining = deadline - time.monotonic()
|
|
389
|
+
if remaining <= 0:
|
|
390
|
+
return None
|
|
391
|
+
wait_seconds = min(_POLL_INTERVAL_SECONDS, remaining)
|
|
392
|
+
if self._stopped.wait(timeout=wait_seconds):
|
|
393
|
+
break
|
|
394
|
+
self.raise_if_failed()
|
|
395
|
+
return self.result
|
|
396
|
+
|
|
397
|
+
def poll(self, timeout: Optional[float] = None) -> Optional[SolareUpdate]:
|
|
398
|
+
"""Return one progress update, or ``None`` on timeout/end-of-stream."""
|
|
399
|
+
|
|
400
|
+
self._require_started()
|
|
401
|
+
_validate_timeout(timeout, name="timeout")
|
|
402
|
+
if self._inside_update_callback():
|
|
403
|
+
raise RuntimeError(
|
|
404
|
+
"poll() cannot consume updates inside on_update; consume "
|
|
405
|
+
"them outside the callback"
|
|
406
|
+
)
|
|
407
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
408
|
+
while True:
|
|
409
|
+
try:
|
|
410
|
+
return self._update_queue.get_nowait()
|
|
411
|
+
except Empty:
|
|
412
|
+
pass
|
|
413
|
+
if self._stopped.is_set():
|
|
414
|
+
self.raise_if_failed()
|
|
415
|
+
return None
|
|
416
|
+
if self._cleanup_incomplete:
|
|
417
|
+
self.raise_if_failed()
|
|
418
|
+
if deadline is None:
|
|
419
|
+
wait_seconds = _POLL_INTERVAL_SECONDS
|
|
420
|
+
else:
|
|
421
|
+
remaining = deadline - time.monotonic()
|
|
422
|
+
if remaining <= 0:
|
|
423
|
+
return None
|
|
424
|
+
wait_seconds = min(_POLL_INTERVAL_SECONDS, remaining)
|
|
425
|
+
try:
|
|
426
|
+
return self._update_queue.get(timeout=wait_seconds)
|
|
427
|
+
except Empty:
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
def updates(self) -> Iterator[SolareUpdate]:
|
|
431
|
+
"""Yield structured progress until the final update is drained."""
|
|
432
|
+
|
|
433
|
+
while True:
|
|
434
|
+
update = self.poll()
|
|
435
|
+
if update is None:
|
|
436
|
+
return
|
|
437
|
+
yield update
|
|
438
|
+
|
|
439
|
+
def raise_if_failed(self) -> None:
|
|
440
|
+
error = self.error
|
|
441
|
+
if error is not None:
|
|
442
|
+
raise error
|
|
443
|
+
|
|
444
|
+
def _run_worker(self) -> None:
|
|
445
|
+
reason = "capture-ended"
|
|
446
|
+
try:
|
|
447
|
+
while not self._stop_requested.is_set():
|
|
448
|
+
capture = self._capture
|
|
449
|
+
if capture is None:
|
|
450
|
+
raise RuntimeError("live capture owner disappeared")
|
|
451
|
+
capture_error = capture.error
|
|
452
|
+
if capture_error is not None:
|
|
453
|
+
raise capture_error
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
packet = self._packet_queue.get(timeout=_POLL_INTERVAL_SECONDS)
|
|
457
|
+
except Empty:
|
|
458
|
+
if not capture.running:
|
|
459
|
+
reason = "capture-ended"
|
|
460
|
+
break
|
|
461
|
+
self._service_drained_clocks()
|
|
462
|
+
tracker = self._tracker
|
|
463
|
+
if (
|
|
464
|
+
self._stop_on_complete
|
|
465
|
+
and tracker is not None
|
|
466
|
+
and tracker.complete
|
|
467
|
+
):
|
|
468
|
+
reason = "complete-snapshot"
|
|
469
|
+
break
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
self._process_packet(packet)
|
|
473
|
+
self._service_drained_clocks()
|
|
474
|
+
tracker = self._tracker
|
|
475
|
+
if (
|
|
476
|
+
self._stop_on_complete
|
|
477
|
+
and tracker is not None
|
|
478
|
+
and tracker.complete
|
|
479
|
+
):
|
|
480
|
+
reason = "complete-snapshot"
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
requested_reason = self.stop_reason
|
|
484
|
+
if requested_reason is not None:
|
|
485
|
+
reason = requested_reason
|
|
486
|
+
except BaseException as exc:
|
|
487
|
+
self._record_error(exc)
|
|
488
|
+
reason = "error"
|
|
489
|
+
finally:
|
|
490
|
+
self._finish_worker(reason)
|
|
491
|
+
|
|
492
|
+
def _finish_worker(self, reason: str) -> None:
|
|
493
|
+
with self._finish_lock:
|
|
494
|
+
if self._stopped.is_set():
|
|
495
|
+
return
|
|
496
|
+
self._stop_requested.set()
|
|
497
|
+
capture = self._capture
|
|
498
|
+
collector = self._collector
|
|
499
|
+
tracker = self._tracker
|
|
500
|
+
stats = None
|
|
501
|
+
result: Optional[SolareCaptureResult] = None
|
|
502
|
+
stop_failure: Optional[BaseException] = None
|
|
503
|
+
if capture is not None:
|
|
504
|
+
try:
|
|
505
|
+
stats = capture.stop()
|
|
506
|
+
except BaseException as exc:
|
|
507
|
+
stop_failure = exc
|
|
508
|
+
self._record_error(exc)
|
|
509
|
+
try:
|
|
510
|
+
if capture.error is not None:
|
|
511
|
+
self._record_error(capture.error)
|
|
512
|
+
except BaseException as exc:
|
|
513
|
+
self._record_error(exc)
|
|
514
|
+
if not capture.stopped:
|
|
515
|
+
cleanup_error = (
|
|
516
|
+
getattr(capture, "cleanup_error", None)
|
|
517
|
+
or stop_failure
|
|
518
|
+
or RuntimeError(
|
|
519
|
+
"live Solare capture cleanup is incomplete"
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
self._record_error(cleanup_error)
|
|
523
|
+
self._cleanup_incomplete = True
|
|
524
|
+
return
|
|
525
|
+
try:
|
|
526
|
+
while True:
|
|
527
|
+
try:
|
|
528
|
+
packet = self._packet_queue.get_nowait()
|
|
529
|
+
except Empty:
|
|
530
|
+
break
|
|
531
|
+
if self._decoder_failed:
|
|
532
|
+
self._write_packet(packet)
|
|
533
|
+
continue
|
|
534
|
+
try:
|
|
535
|
+
self._process_packet(packet)
|
|
536
|
+
except BaseException as exc:
|
|
537
|
+
self._record_error(exc)
|
|
538
|
+
self._decoder_failed = True
|
|
539
|
+
|
|
540
|
+
if collector is None or tracker is None:
|
|
541
|
+
self._record_error(
|
|
542
|
+
RuntimeError("live Solare decoder was not initialized")
|
|
543
|
+
)
|
|
544
|
+
self._decoder_failed = True
|
|
545
|
+
else:
|
|
546
|
+
try:
|
|
547
|
+
collector.finish()
|
|
548
|
+
except BaseException as exc:
|
|
549
|
+
self._record_error(exc)
|
|
550
|
+
self._decoder_failed = True
|
|
551
|
+
if not self._decoder_failed:
|
|
552
|
+
try:
|
|
553
|
+
tracker.refresh()
|
|
554
|
+
except BaseException as exc:
|
|
555
|
+
self._record_error(exc)
|
|
556
|
+
self._decoder_failed = True
|
|
557
|
+
|
|
558
|
+
self._close_writer()
|
|
559
|
+
|
|
560
|
+
health: Optional[SolareCaptureHealth] = None
|
|
561
|
+
if collector is not None:
|
|
562
|
+
try:
|
|
563
|
+
health = self._collector_health(collector, stats)
|
|
564
|
+
except BaseException as exc:
|
|
565
|
+
self._record_error(exc)
|
|
566
|
+
|
|
567
|
+
if (
|
|
568
|
+
collector is not None
|
|
569
|
+
and tracker is not None
|
|
570
|
+
and health is not None
|
|
571
|
+
and not self._decoder_failed
|
|
572
|
+
):
|
|
573
|
+
try:
|
|
574
|
+
confirmed_frames = getattr(
|
|
575
|
+
tracker,
|
|
576
|
+
"confirmed_frames",
|
|
577
|
+
None,
|
|
578
|
+
)
|
|
579
|
+
retained_frames = getattr(
|
|
580
|
+
tracker,
|
|
581
|
+
"retained_frames",
|
|
582
|
+
(),
|
|
583
|
+
)
|
|
584
|
+
result_frames = (
|
|
585
|
+
confirmed_frames
|
|
586
|
+
or retained_frames
|
|
587
|
+
or collector.frames
|
|
588
|
+
)
|
|
589
|
+
discovery = getattr(tracker, "result", None)
|
|
590
|
+
if discovery is None:
|
|
591
|
+
result = build_solare_result(
|
|
592
|
+
result_frames,
|
|
593
|
+
self._confirmed_health or health,
|
|
594
|
+
retain_raw_extensions=self._retain_raw_extensions,
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
result = build_solare_result(
|
|
598
|
+
result_frames,
|
|
599
|
+
self._confirmed_health or health,
|
|
600
|
+
retain_raw_extensions=self._retain_raw_extensions,
|
|
601
|
+
_discovery=discovery,
|
|
602
|
+
)
|
|
603
|
+
resource_loss = _resource_loss_summary(health)
|
|
604
|
+
if (
|
|
605
|
+
result.complete
|
|
606
|
+
and self._confirmed_health is not None
|
|
607
|
+
and self._confirmed_health.capture_is_clean
|
|
608
|
+
and resource_loss is not None
|
|
609
|
+
):
|
|
610
|
+
base_message = result.message or (
|
|
611
|
+
"structurally confirmed Solare leaderboard"
|
|
612
|
+
)
|
|
613
|
+
if (
|
|
614
|
+
reason == "resource-limit"
|
|
615
|
+
or self.stop_reason == "resource-limit"
|
|
616
|
+
):
|
|
617
|
+
continuation = (
|
|
618
|
+
"the already-confirmed snapshot remains "
|
|
619
|
+
"valid, but continued capture ended due to "
|
|
620
|
+
f"resource pressure: {resource_loss}"
|
|
621
|
+
)
|
|
622
|
+
else:
|
|
623
|
+
continuation = (
|
|
624
|
+
"the already-confirmed snapshot remains "
|
|
625
|
+
"valid, but continued capture later lost "
|
|
626
|
+
f"integrity because {resource_loss}"
|
|
627
|
+
)
|
|
628
|
+
result = replace(
|
|
629
|
+
result,
|
|
630
|
+
message=f"{base_message}; {continuation}",
|
|
631
|
+
)
|
|
632
|
+
except BaseException as exc:
|
|
633
|
+
self._record_error(exc)
|
|
634
|
+
|
|
635
|
+
with self._state_lock:
|
|
636
|
+
self._result = result
|
|
637
|
+
|
|
638
|
+
if result is not None:
|
|
639
|
+
resource_loss = (
|
|
640
|
+
_resource_loss_summary(health)
|
|
641
|
+
if health is not None
|
|
642
|
+
else None
|
|
643
|
+
)
|
|
644
|
+
if resource_loss is not None:
|
|
645
|
+
self._emit(
|
|
646
|
+
SolareUpdate(
|
|
647
|
+
kind=SolareUpdateKind.WARNING,
|
|
648
|
+
message=(
|
|
649
|
+
"live capture integrity was lost because "
|
|
650
|
+
f"{resource_loss}; retry after reducing "
|
|
651
|
+
"capture load"
|
|
652
|
+
),
|
|
653
|
+
ranked_players=result.evidence.ranked_players,
|
|
654
|
+
overall_players=result.evidence.overall_players,
|
|
655
|
+
exact_cross_check=(
|
|
656
|
+
result.evidence.exact_cross_check
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
self._emit(
|
|
661
|
+
SolareUpdate(
|
|
662
|
+
kind=SolareUpdateKind.FINISHED,
|
|
663
|
+
message=(
|
|
664
|
+
f"Solare capture finished with status "
|
|
665
|
+
f"{result.status.value}"
|
|
666
|
+
),
|
|
667
|
+
ranked_players=result.evidence.ranked_players,
|
|
668
|
+
overall_players=result.evidence.overall_players,
|
|
669
|
+
exact_cross_check=result.evidence.exact_cross_check,
|
|
670
|
+
result=result,
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
except BaseException as exc:
|
|
674
|
+
self._record_error(exc)
|
|
675
|
+
finally:
|
|
676
|
+
self._close_writer()
|
|
677
|
+
with self._state_lock:
|
|
678
|
+
if self._error is not None:
|
|
679
|
+
self._stop_reason = "error"
|
|
680
|
+
elif self._stop_reason is None:
|
|
681
|
+
self._stop_reason = reason
|
|
682
|
+
self._collector = None
|
|
683
|
+
self._tracker = None
|
|
684
|
+
self._packet_handler = None
|
|
685
|
+
self._capture = None
|
|
686
|
+
self._confirmed_health = None
|
|
687
|
+
self._worker = None
|
|
688
|
+
self._cleanup_incomplete = False
|
|
689
|
+
self._stopped.set()
|
|
690
|
+
|
|
691
|
+
def _process_packet(self, packet: object) -> None:
|
|
692
|
+
# Record the exact packet accepted by the capture callback before any
|
|
693
|
+
# semantic work can fail. This makes an opt-in PCAP useful for patch
|
|
694
|
+
# drift and decoder failures, while all writing still stays off the
|
|
695
|
+
# native capture thread.
|
|
696
|
+
self._write_packet(packet)
|
|
697
|
+
handler = self._packet_handler
|
|
698
|
+
if handler is None:
|
|
699
|
+
raise RuntimeError("live Solare packet handler was not initialized")
|
|
700
|
+
try:
|
|
701
|
+
handler(packet)
|
|
702
|
+
except BaseException:
|
|
703
|
+
self._decoder_failed = True
|
|
704
|
+
raise
|
|
705
|
+
self._announce_tcp_gap_loss()
|
|
706
|
+
|
|
707
|
+
tracker = self._tracker
|
|
708
|
+
if tracker is not None and tracker.complete:
|
|
709
|
+
self._latch_confirmation()
|
|
710
|
+
|
|
711
|
+
def _service_drained_clocks(self) -> None:
|
|
712
|
+
"""Advance live-only clocks only after queued packet work is caught up.
|
|
713
|
+
|
|
714
|
+
Wall-clock TCP-gap recovery or candidate-tail closure while packets
|
|
715
|
+
remain queued could skip a delayed prefix or a later same-family
|
|
716
|
+
frame that Npcap already delivered. Waiting for an empty decode queue
|
|
717
|
+
preserves that evidence. The second check protects candidate
|
|
718
|
+
finalization from packets enqueued while gap service was running.
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
if not self._packet_queue.empty():
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
collector = self._collector
|
|
725
|
+
if collector is not None:
|
|
726
|
+
collector.service_gaps(time.time())
|
|
727
|
+
self._announce_tcp_gap_loss()
|
|
728
|
+
|
|
729
|
+
if not self._packet_queue.empty():
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
tracker = self._tracker
|
|
733
|
+
if tracker is not None:
|
|
734
|
+
tracker.service_candidate_idle(time.monotonic())
|
|
735
|
+
self._latch_confirmation()
|
|
736
|
+
|
|
737
|
+
def _write_packet(self, packet: object) -> None:
|
|
738
|
+
if self._writer is not None:
|
|
739
|
+
try:
|
|
740
|
+
self._writer.write(packet)
|
|
741
|
+
self._saved_packets += 1
|
|
742
|
+
if self._saved_packets % 128 == 0:
|
|
743
|
+
self._writer.flush()
|
|
744
|
+
except BaseException as exc:
|
|
745
|
+
self._record_error(exc)
|
|
746
|
+
self._set_requested_reason("error")
|
|
747
|
+
self._stop_requested.set()
|
|
748
|
+
self._close_writer()
|
|
749
|
+
|
|
750
|
+
def _latch_confirmation(self) -> None:
|
|
751
|
+
tracker = self._tracker
|
|
752
|
+
collector = self._collector
|
|
753
|
+
if tracker is None or not tracker.complete or collector is None:
|
|
754
|
+
return
|
|
755
|
+
if self._confirmed_health is None:
|
|
756
|
+
capture = self._capture
|
|
757
|
+
stats = capture.snapshot_stats() if capture is not None else None
|
|
758
|
+
self._confirmed_health = self._collector_health(collector, stats)
|
|
759
|
+
collector.stop_retaining()
|
|
760
|
+
|
|
761
|
+
def _collector_health(
|
|
762
|
+
self,
|
|
763
|
+
collector: SolareFrameCollector,
|
|
764
|
+
stats: Optional[CaptureStats],
|
|
765
|
+
) -> SolareCaptureHealth:
|
|
766
|
+
tracker = self._tracker
|
|
767
|
+
return collector.health(
|
|
768
|
+
saved_packets=self._saved_packets,
|
|
769
|
+
pcap_received=stats.received if stats is not None else None,
|
|
770
|
+
pcap_dropped=stats.dropped if stats is not None else None,
|
|
771
|
+
pcap_interface_dropped=(
|
|
772
|
+
stats.interface_dropped if stats is not None else None
|
|
773
|
+
),
|
|
774
|
+
capture_buffer_bytes=(
|
|
775
|
+
stats.capture_buffer_bytes if stats is not None else None
|
|
776
|
+
),
|
|
777
|
+
retained_large_messages=getattr(
|
|
778
|
+
tracker,
|
|
779
|
+
"observed_frame_count",
|
|
780
|
+
len(collector.frames),
|
|
781
|
+
),
|
|
782
|
+
candidate_messages_observed=getattr(
|
|
783
|
+
tracker,
|
|
784
|
+
"observed_frame_count",
|
|
785
|
+
0,
|
|
786
|
+
),
|
|
787
|
+
candidate_frames_retained=getattr(
|
|
788
|
+
tracker,
|
|
789
|
+
"retained_frame_count",
|
|
790
|
+
0,
|
|
791
|
+
),
|
|
792
|
+
candidate_bytes_retained=getattr(
|
|
793
|
+
tracker,
|
|
794
|
+
"retained_bytes",
|
|
795
|
+
0,
|
|
796
|
+
),
|
|
797
|
+
peak_candidate_frames=getattr(
|
|
798
|
+
tracker,
|
|
799
|
+
"peak_retained_frame_count",
|
|
800
|
+
0,
|
|
801
|
+
),
|
|
802
|
+
peak_candidate_bytes=getattr(
|
|
803
|
+
tracker,
|
|
804
|
+
"peak_retained_bytes",
|
|
805
|
+
0,
|
|
806
|
+
),
|
|
807
|
+
candidate_frames_evicted=getattr(
|
|
808
|
+
tracker,
|
|
809
|
+
"evicted_frame_count",
|
|
810
|
+
0,
|
|
811
|
+
),
|
|
812
|
+
candidate_bytes_evicted=getattr(
|
|
813
|
+
tracker,
|
|
814
|
+
"evicted_bytes",
|
|
815
|
+
0,
|
|
816
|
+
),
|
|
817
|
+
candidate_history_rolled_over=getattr(
|
|
818
|
+
tracker,
|
|
819
|
+
"history_rolled_over",
|
|
820
|
+
False,
|
|
821
|
+
),
|
|
822
|
+
packet_queue_peak=self._packet_queue_peak,
|
|
823
|
+
packet_queue_overflows=self._packet_queue_overflows,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
def _announce_traffic(self) -> None:
|
|
827
|
+
if self._traffic_announced:
|
|
828
|
+
return
|
|
829
|
+
self._traffic_announced = True
|
|
830
|
+
self._emit(
|
|
831
|
+
SolareUpdate(
|
|
832
|
+
kind=SolareUpdateKind.TRAFFIC,
|
|
833
|
+
message="inbound game-server traffic is flowing",
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
def _announce_tcp_gap_loss(self) -> None:
|
|
838
|
+
"""Warn once when this pre-confirmation attempt becomes ineligible."""
|
|
839
|
+
|
|
840
|
+
collector = self._collector
|
|
841
|
+
if (
|
|
842
|
+
self._tcp_gap_warning_announced
|
|
843
|
+
or self._confirmed_health is not None
|
|
844
|
+
or collector is None
|
|
845
|
+
or collector.tcp_gap_resets == 0
|
|
846
|
+
):
|
|
847
|
+
return
|
|
848
|
+
self._tcp_gap_warning_announced = True
|
|
849
|
+
count = collector.tcp_gap_resets
|
|
850
|
+
self._emit(
|
|
851
|
+
SolareUpdate(
|
|
852
|
+
kind=SolareUpdateKind.WARNING,
|
|
853
|
+
message=(
|
|
854
|
+
"TCP reassembly reset "
|
|
855
|
+
f"{count} time{'s' if count != 1 else ''} after an "
|
|
856
|
+
"unresolved sequence gap; this capture attempt will "
|
|
857
|
+
"remain fail-closed. Stop and retry with a fresh capture"
|
|
858
|
+
),
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
def _enqueue_packet(self, packet: object) -> None:
|
|
863
|
+
if self._stop_requested.is_set():
|
|
864
|
+
return
|
|
865
|
+
try:
|
|
866
|
+
self._packet_queue.put_nowait(packet)
|
|
867
|
+
except Full:
|
|
868
|
+
with self._state_lock:
|
|
869
|
+
self._packet_queue_overflows += 1
|
|
870
|
+
if self._stop_reason is None:
|
|
871
|
+
self._stop_reason = "resource-limit"
|
|
872
|
+
self._stop_requested.set()
|
|
873
|
+
return
|
|
874
|
+
depth = self._packet_queue.qsize()
|
|
875
|
+
with self._state_lock:
|
|
876
|
+
self._packet_queue_peak = max(self._packet_queue_peak, depth)
|
|
877
|
+
|
|
878
|
+
def _emit(self, update: SolareUpdate) -> None:
|
|
879
|
+
# Structural confirmation is atomic before user code observes it.
|
|
880
|
+
# A slow callback may allow later packets or resource loss to arrive,
|
|
881
|
+
# but those events cannot retroactively invalidate the proven window.
|
|
882
|
+
if update.kind is SolareUpdateKind.SNAPSHOT_CONFIRMED:
|
|
883
|
+
self._latch_confirmation()
|
|
884
|
+
self._queue_update(update)
|
|
885
|
+
self._notify_update(update)
|
|
886
|
+
|
|
887
|
+
def _queue_update(self, update: SolareUpdate) -> None:
|
|
888
|
+
while True:
|
|
889
|
+
try:
|
|
890
|
+
self._update_queue.put_nowait(update)
|
|
891
|
+
break
|
|
892
|
+
except Full:
|
|
893
|
+
try:
|
|
894
|
+
self._update_queue.get_nowait()
|
|
895
|
+
except Empty:
|
|
896
|
+
continue
|
|
897
|
+
|
|
898
|
+
def _notify_update(self, update: SolareUpdate) -> None:
|
|
899
|
+
if self._on_update is not None:
|
|
900
|
+
active_sessions = _ACTIVE_UPDATE_SESSIONS.get()
|
|
901
|
+
token = _ACTIVE_UPDATE_SESSIONS.set(active_sessions + (self,))
|
|
902
|
+
try:
|
|
903
|
+
self._on_update(update)
|
|
904
|
+
except BaseException as exc:
|
|
905
|
+
self._record_error(exc)
|
|
906
|
+
self._set_requested_reason("error")
|
|
907
|
+
self._stop_requested.set()
|
|
908
|
+
finally:
|
|
909
|
+
_ACTIVE_UPDATE_SESSIONS.reset(token)
|
|
910
|
+
|
|
911
|
+
def _inside_update_callback(self) -> bool:
|
|
912
|
+
return self in _ACTIVE_UPDATE_SESSIONS.get()
|
|
913
|
+
|
|
914
|
+
def _close_writer(self) -> None:
|
|
915
|
+
writer = self._writer
|
|
916
|
+
if writer is None:
|
|
917
|
+
return
|
|
918
|
+
self._writer = None
|
|
919
|
+
try:
|
|
920
|
+
writer.close()
|
|
921
|
+
except BaseException as exc:
|
|
922
|
+
self._record_error(exc)
|
|
923
|
+
|
|
924
|
+
def _record_error(self, error: BaseException) -> None:
|
|
925
|
+
with self._state_lock:
|
|
926
|
+
if self._error is None:
|
|
927
|
+
self._error = error
|
|
928
|
+
|
|
929
|
+
def _set_requested_reason(self, reason: str) -> None:
|
|
930
|
+
with self._state_lock:
|
|
931
|
+
if self._stop_reason is None:
|
|
932
|
+
self._stop_reason = reason
|
|
933
|
+
|
|
934
|
+
def _prepare_save_path(self) -> Optional[Path]:
|
|
935
|
+
if self._save_pcap is None:
|
|
936
|
+
return None
|
|
937
|
+
path = self._save_pcap.expanduser().resolve()
|
|
938
|
+
if path.suffix.lower() not in {".pcap", ".pcapng"}:
|
|
939
|
+
raise ValueError("save_pcap must end in .pcap or .pcapng")
|
|
940
|
+
if path.exists():
|
|
941
|
+
raise FileExistsError(f"refusing to overwrite existing capture: {path}")
|
|
942
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
943
|
+
return path
|
|
944
|
+
|
|
945
|
+
@staticmethod
|
|
946
|
+
def _open_writer(path: Path) -> Any:
|
|
947
|
+
from scapy.utils import PcapNgWriter, PcapWriter # type: ignore
|
|
948
|
+
|
|
949
|
+
if path.suffix.lower() == ".pcapng":
|
|
950
|
+
return PcapNgWriter(str(path))
|
|
951
|
+
return PcapWriter(str(path), sync=True)
|
|
952
|
+
|
|
953
|
+
def _require_started(self) -> None:
|
|
954
|
+
if not self._started:
|
|
955
|
+
raise RuntimeError("live Solare session was not started")
|
|
956
|
+
|
|
957
|
+
def __enter__(self) -> "LiveSolareSession":
|
|
958
|
+
self.start()
|
|
959
|
+
return self
|
|
960
|
+
|
|
961
|
+
def __exit__(
|
|
962
|
+
self,
|
|
963
|
+
exc_type: type[BaseException] | None,
|
|
964
|
+
exc_value: BaseException | None,
|
|
965
|
+
traceback: TracebackType | None,
|
|
966
|
+
) -> None:
|
|
967
|
+
if not self._started:
|
|
968
|
+
return
|
|
969
|
+
if exc_value is None:
|
|
970
|
+
self.stop()
|
|
971
|
+
return
|
|
972
|
+
try:
|
|
973
|
+
self.stop()
|
|
974
|
+
except BaseException as cleanup_error:
|
|
975
|
+
# The exception already leaving the user's block remains primary;
|
|
976
|
+
# the first session failure is still available through ``error``.
|
|
977
|
+
if self.cleanup_incomplete:
|
|
978
|
+
_attach_cleanup_owner(
|
|
979
|
+
exc_value,
|
|
980
|
+
self,
|
|
981
|
+
context="live Solare context",
|
|
982
|
+
)
|
|
983
|
+
if hasattr(exc_value, "add_note"):
|
|
984
|
+
exc_value.add_note(
|
|
985
|
+
"live Solare context cleanup also failed: "
|
|
986
|
+
f"{cleanup_error!r}"
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def capture_solare_snapshot(
|
|
991
|
+
*,
|
|
992
|
+
capture_options: Optional[PacketCaptureOptions] = None,
|
|
993
|
+
capture_seconds: Optional[float] = SOLARE_DEFAULT_CAPTURE_SECONDS,
|
|
994
|
+
save_pcap: str | Path | None = None,
|
|
995
|
+
stop_on_complete: bool = True,
|
|
996
|
+
retain_raw_extensions: bool = False,
|
|
997
|
+
on_update: Optional[Callable[[SolareUpdate], None]] = None,
|
|
998
|
+
capture_buffer_bytes: int = LIVE_CAPTURE_BUFFER_BYTES,
|
|
999
|
+
) -> SolareCaptureResult:
|
|
1000
|
+
"""Blocking convenience wrapper around :class:`LiveSolareSession`."""
|
|
1001
|
+
|
|
1002
|
+
_validate_timeout(capture_seconds, name="capture_seconds")
|
|
1003
|
+
session = LiveSolareSession(
|
|
1004
|
+
capture_options=capture_options,
|
|
1005
|
+
save_pcap=save_pcap,
|
|
1006
|
+
stop_on_complete=stop_on_complete,
|
|
1007
|
+
retain_raw_extensions=retain_raw_extensions,
|
|
1008
|
+
on_update=on_update,
|
|
1009
|
+
capture_buffer_bytes=capture_buffer_bytes,
|
|
1010
|
+
)
|
|
1011
|
+
session.start()
|
|
1012
|
+
started_at = time.monotonic()
|
|
1013
|
+
try:
|
|
1014
|
+
while True:
|
|
1015
|
+
timeout = _POLL_INTERVAL_SECONDS
|
|
1016
|
+
if capture_seconds is not None:
|
|
1017
|
+
remaining = capture_seconds - (time.monotonic() - started_at)
|
|
1018
|
+
if remaining <= 0:
|
|
1019
|
+
return session.stop()
|
|
1020
|
+
timeout = min(timeout, remaining)
|
|
1021
|
+
result = session.wait(timeout=timeout)
|
|
1022
|
+
if result is not None:
|
|
1023
|
+
return result
|
|
1024
|
+
except KeyboardInterrupt:
|
|
1025
|
+
try:
|
|
1026
|
+
return session.stop()
|
|
1027
|
+
except BaseException as exc:
|
|
1028
|
+
if session.cleanup_incomplete:
|
|
1029
|
+
_attach_cleanup_owner(
|
|
1030
|
+
exc,
|
|
1031
|
+
session,
|
|
1032
|
+
context="live Solare convenience wrapper",
|
|
1033
|
+
)
|
|
1034
|
+
raise
|
|
1035
|
+
except BaseException as exc:
|
|
1036
|
+
if session.running:
|
|
1037
|
+
try:
|
|
1038
|
+
session.stop()
|
|
1039
|
+
except BaseException as cleanup_error:
|
|
1040
|
+
if session.cleanup_incomplete:
|
|
1041
|
+
_attach_cleanup_owner(
|
|
1042
|
+
exc,
|
|
1043
|
+
session,
|
|
1044
|
+
context="live Solare convenience wrapper",
|
|
1045
|
+
)
|
|
1046
|
+
if cleanup_error is not exc and hasattr(exc, "add_note"):
|
|
1047
|
+
exc.add_note(
|
|
1048
|
+
"live Solare convenience cleanup also failed: "
|
|
1049
|
+
f"{cleanup_error!r}"
|
|
1050
|
+
)
|
|
1051
|
+
raise
|