Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/pktai_tui/services/capture.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from __future__ import annotations

import asyncio
from pathlib import Path
from typing import Callable, Any

Expand All @@ -14,6 +16,25 @@
EmitFn = Callable[[PacketRow, str | None, dict[str, str], str | None, dict[str, list[str]]], None]


def _get_or_create_event_loop() -> tuple[asyncio.AbstractEventLoop, bool]:
try:
return asyncio.get_event_loop_policy().get_event_loop(), False
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop, True


def _close_owned_event_loop(loop: asyncio.AbstractEventLoop) -> None:
if not loop.is_closed():
loop.close()
try:
if asyncio.get_event_loop_policy().get_event_loop() is loop:
asyncio.set_event_loop(None)
except RuntimeError:
pass


def _safe_attr(obj, name: str, default: str = "") -> str:
try:
return getattr(obj, name)
Expand Down Expand Up @@ -283,9 +304,12 @@ def parse_capture(
notify_error("PyShark is not installed. Please run: uv sync")
return

eventloop, close_eventloop = _get_or_create_event_loop()
try:
cap = pyshark.FileCapture(str(path), keep_packets=False)
cap = pyshark.FileCapture(str(path), keep_packets=False, eventloop=eventloop)
except Exception as e: # pragma: no cover
if close_eventloop:
_close_owned_event_loop(eventloop)
if notify_error:
notify_error(f"Failed to open capture: {e}")
return
Expand All @@ -307,6 +331,8 @@ def parse_capture(
cap.close()
except Exception:
pass
if close_eventloop:
_close_owned_event_loop(eventloop)


def packets_to_text(packets: list[object], *, max_packets: int = 200, max_chars: int = 50000) -> str:
Expand Down
55 changes: 55 additions & 0 deletions tests/test_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import asyncio
import threading
from pathlib import Path
from types import SimpleNamespace

from pktai_tui.services import capture


def test_get_or_create_event_loop_sets_loop_in_worker_thread():
results = []

def worker():
loop, should_close = capture._get_or_create_event_loop()
try:
current_loop = asyncio.get_event_loop_policy().get_event_loop()
results.append((should_close, current_loop is loop, loop.is_closed()))
finally:
capture._close_owned_event_loop(loop)
results.append(("closed", loop.is_closed()))

thread = threading.Thread(target=worker)
thread.start()
thread.join()

assert results == [(True, True, False), ("closed", True)]


def test_parse_capture_passes_event_loop_to_pyshark(monkeypatch):
loop = asyncio.new_event_loop()
captures = []

class FakeFileCapture:
def __init__(self, path, keep_packets, eventloop):
self.path = path
self.keep_packets = keep_packets
self.eventloop = eventloop
self.closed = False
captures.append(self)

def __iter__(self):
return iter(())

def close(self):
self.closed = True

monkeypatch.setattr(capture, "_get_or_create_event_loop", lambda: (loop, True))
monkeypatch.setattr(capture, "pyshark", SimpleNamespace(FileCapture=FakeFileCapture))

capture.parse_capture(Path("capture.pcap"), emit=lambda *args: None)

assert captures[0].path == "capture.pcap"
assert captures[0].keep_packets is False
assert captures[0].eventloop is loop
assert captures[0].closed is True
assert loop.is_closed()