"""EzSession - runtime ownership for Qt/ezmsg integration."""
from __future__ import annotations
import asyncio
import logging
import signal
import socket
import threading
import weakref
from collections.abc import Callable
from concurrent.futures import TimeoutError as FutureTimeoutError
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING
from ezmsg.core.backend import GraphRunner
from ezmsg.core.graphcontext import GraphContext
from ezmsg.core.netprotocol import AddressType
from ezmsg.core.pubclient import Publisher as EzPublisherClient
from ezmsg.core.subclient import Subscriber as EzSubscriberClient
from qtpy import QtCore
from qtpy import QtWidgets
from .gate import GateMessage
from .sidecar import build_sidecar_components
from .sidecar import CompiledGraph
from .sidecar import CompiledSink
from .sidecar import normalize_topic
if TYPE_CHECKING:
from .chain import ProcessorGraph
from .publisher import EzPublisher
from .subscriber import EzSubscriber
logger = logging.getLogger(__name__)
_TOPIC_SWITCH_TIMEOUT = 5.0
_RUNTIME_OPERATION_TIMEOUT = 5.0
@dataclass
class _SubscriberRuntime:
switch_lock: asyncio.Lock
client: Any | None = None
task: asyncio.Task[None] | None = None
active_topic: str | None = None
gate_open: bool = True
@dataclass
class _PublisherRuntime:
switch_lock: asyncio.Lock
client: Any | None = None
task: asyncio.Task[None] | None = None
active_topic: str | None = None
@dataclass
class _PipelineRuntime:
compiled: CompiledGraph
sink_clients: list[Any]
sink_tasks: list[asyncio.Task[None]]
gate_publisher: Any | None = None
class _QtSignalDispatcher(QtCore.QObject):
"""Dispatch work onto the Qt main thread."""
call_signal = QtCore.Signal(object, object) # pyright: ignore[reportPrivateImportUsage]
def __init__(self, parent: QtCore.QObject | None = None):
super().__init__(parent)
self.call_signal.connect(self._on_call)
def _on_call(self, func, args) -> None:
func(*args)
def schedule(self, func, *args) -> None:
self.call_signal.emit(func, args)
[docs]
class EzSession:
"""Runtime owner for Qt/ezmsg endpoints and processor graphs."""
[docs]
def __init__(
self,
graph_address: AddressType | None = None,
):
self._graph_address = graph_address
self._context: GraphContext | None = None
self._context_entered = False
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._shutdown = threading.Event()
self._context_ready = threading.Event()
self._startup_release = threading.Event()
self._setup_complete = threading.Event()
self._setup_error: BaseException | None = None
self._running = False
self._dispatcher: _QtSignalDispatcher | None = None
self._tasks: set[asyncio.Task[None]] = set()
self._subscribers: dict[int, EzSubscriber] = {}
self._publishers: dict[int, EzPublisher] = {}
self._pipelines: dict[int, ProcessorGraph] = {}
self._subscriber_runtime: dict[int, _SubscriberRuntime] = {}
self._publisher_runtime: dict[int, _PublisherRuntime] = {}
self._pipeline_runtime: dict[int, _PipelineRuntime] = {}
self._compiled_pipelines: list[CompiledGraph] = []
self._sidecar: GraphRunner | None = None
self._chain_counter = 0
self._topic_prefix = f"_qt.{id(self):x}"
self._prev_sigint_handler = None
self._sigint_notifier: QtCore.QSocketNotifier | None = None
self._wakeup_sock_r: socket.socket | None = None
self._wakeup_sock_w: socket.socket | None = None
self._wakeup_prev_fd: int | None = None
@property
def running(self) -> bool:
"""Whether the session is active and ready to service runtime requests."""
return self._running and self._loop is not None and not self._loop.is_closed()
@property
def graph_address(self) -> AddressType | None:
if self._context is not None:
return self._context.graph_address
if self._sidecar is not None and self._sidecar.graph_address is not None:
return self._sidecar.graph_address
return self._graph_address
[docs]
def attach(self, attachable):
"""Attach a subscriber, publisher, or processor graph to this session."""
from .chain import ProcessorGraph
from .publisher import EzPublisher
from .subscriber import EzSubscriber
if isinstance(attachable, EzSubscriber):
self._attach_subscriber(attachable)
elif isinstance(attachable, EzPublisher):
self._attach_publisher(attachable)
elif isinstance(attachable, ProcessorGraph):
self._attach_pipeline(attachable)
else:
raise TypeError(f"Unsupported attachable type: {type(attachable)!r}")
return attachable
[docs]
def detach(self, attachable) -> None:
"""Detach a subscriber or publisher from this session."""
from .publisher import EzPublisher
from .subscriber import EzSubscriber
if isinstance(attachable, EzSubscriber):
self._detach_subscriber(id(attachable))
return
if isinstance(attachable, EzPublisher):
self._detach_publisher(id(attachable))
return
raise TypeError("Only EzSubscriber and EzPublisher can be detached at runtime")
def _attach_subscriber(self, subscriber: EzSubscriber) -> None:
key = id(subscriber)
if key in self._subscribers:
return
subscriber._bind_session(self)
self._subscribers[key] = subscriber
self._bind_destroyed(subscriber, self._detach_subscriber, key)
if self._running and self._loop is not None:
self._wait_for_runtime_operation(
asyncio.run_coroutine_threadsafe(
self._setup_subscriber(subscriber), self._loop
),
"subscriber attach",
)
def _set_subscriber_topic(self, subscriber: EzSubscriber, topic) -> None:
if not self.running or self._loop is None:
raise RuntimeError(
"EzSubscriber topic switching requires a running EzSession"
)
self._wait_for_runtime_operation(
asyncio.run_coroutine_threadsafe(
self._switch_subscriber(subscriber, topic), self._loop
),
"subscriber topic switch",
timeout=_TOPIC_SWITCH_TIMEOUT,
)
def _attach_publisher(self, publisher: EzPublisher) -> None:
key = id(publisher)
if key in self._publishers:
return
publisher._bind_session(self)
self._publishers[key] = publisher
self._bind_destroyed(publisher, self._detach_publisher, key)
if self._running and self._loop is not None:
self._wait_for_runtime_operation(
asyncio.run_coroutine_threadsafe(
self._setup_publisher(publisher), self._loop
),
"publisher attach",
)
def _set_publisher_topic(self, publisher: EzPublisher, topic) -> None:
if not self.running or self._loop is None:
raise RuntimeError(
"EzPublisher topic switching requires a running EzSession"
)
self._wait_for_runtime_operation(
asyncio.run_coroutine_threadsafe(
self._switch_publisher(publisher, topic), self._loop
),
"publisher topic switch",
timeout=_TOPIC_SWITCH_TIMEOUT,
)
def _attach_pipeline(self, graph: "ProcessorGraph") -> None:
key = id(graph)
if key in self._pipelines:
return
if self._running:
raise RuntimeError("Processor graphs must be attached before session start")
graph._validate()
graph._bind_session(self)
if graph._graph_id is None:
graph._graph_id = f"graph_{self._chain_counter}"
self._chain_counter += 1
self._pipelines[key] = graph
def _bind_destroyed(self, obj: QtCore.QObject, callback, key: int) -> None:
obj.destroyed.connect(lambda *_args, _key=key: callback(_key))
def _detach_subscriber(self, key: int) -> None:
subscriber = self._subscribers.pop(key, None)
if subscriber is None:
return
runtime = self._subscriber_runtime.pop(key, None)
if runtime is None or self._loop is None or self._loop.is_closed():
return
try:
self._wait_for_runtime_operation(
asyncio.run_coroutine_threadsafe(
self._close_subscriber_runtime(runtime, subscriber), self._loop
),
"subscriber detach",
)
except RuntimeError:
logger.debug("Subscriber detached after loop shutdown")
def _detach_publisher(self, key: int) -> None:
publisher = self._publishers.pop(key, None)
if publisher is None:
return
runtime = self._publisher_runtime.pop(key, None)
if runtime is None or self._loop is None or self._loop.is_closed():
return
try:
self._wait_for_runtime_operation(
asyncio.run_coroutine_threadsafe(
self._close_publisher_runtime(runtime, publisher), self._loop
),
"publisher detach",
)
except RuntimeError:
logger.debug("Publisher detached after loop shutdown")
def __enter__(self) -> EzSession:
self._running = True
self._setup_error = None
self._shutdown.clear()
self._context_ready.clear()
self._startup_release.clear()
self._setup_complete.clear()
if self._dispatcher is None:
self._dispatcher = _QtSignalDispatcher(QtWidgets.QApplication.instance())
self._install_sigint_handler()
self._context = GraphContext(self._graph_address)
self._thread = threading.Thread(
target=self._run_async_loop, daemon=True, name="EzSession"
)
self._thread.start()
try:
if not self._context_ready.wait(timeout=30.0):
if self._setup_error is not None:
raise RuntimeError("EzSession setup failed") from self._setup_error
raise RuntimeError("EzSession setup timed out")
self._prepare_sidecar()
self._startup_release.set()
if not self._setup_complete.wait(timeout=30.0):
raise RuntimeError("EzSession setup timed out")
if self._setup_error is not None:
raise RuntimeError("EzSession setup failed") from self._setup_error
except Exception:
self._startup_release.set()
self._abort_startup()
raise
return self
def __exit__(self, exc_type, exc, exc_tb) -> bool | None:
self._running = False
if exc_type is KeyboardInterrupt:
app = QtWidgets.QApplication.instance()
if app is not None:
app.quit()
if self._sidecar is not None:
try:
self._sidecar.stop()
finally:
self._sidecar = None
self._shutdown.set()
if self._thread is not None:
self._thread.join(timeout=10.0)
self._thread = None
self._restore_sigint_handler()
if exc_type is KeyboardInterrupt:
return False
return None
def _run_async_loop(self) -> None:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._async_main())
except Exception:
logger.exception("Error in EzSession async loop")
finally:
self._loop.close()
self._loop = None
async def _async_main(self) -> None:
if self._context is None:
raise RuntimeError("Session graph context is not initialized")
try:
try:
await self._context.__aenter__()
self._context_entered = True
self._context_ready.set()
await asyncio.to_thread(self._startup_release.wait)
await self._async_setup()
except BaseException as exc:
self._setup_error = exc
finally:
self._setup_complete.set()
if self._setup_error is None:
while not self._shutdown.is_set():
await asyncio.sleep(0.1)
finally:
await self._async_cleanup()
if self._setup_error is not None:
raise self._setup_error
async def _async_setup(self) -> None:
for subscriber in list(self._subscribers.values()):
await self._setup_subscriber(subscriber)
for publisher in list(self._publishers.values()):
await self._setup_publisher(publisher)
for compiled in self._compiled_pipelines:
await self._setup_pipeline_runtime(compiled)
def _prepare_sidecar(self) -> None:
"""Start the sidecar runner before entering the session graph context.
This runs after the session graph context is ready, so sidecar graphs
join the same graph without replacing the session GraphContext.
"""
graphs = list(self._pipelines.values())
if not graphs:
self._compiled_pipelines = []
return
components, connections, process_components, compiled_graphs = (
build_sidecar_components(graphs, topic_prefix=self._topic_prefix)
)
runner = GraphRunner(
components=components,
connections=connections,
process_components=process_components,
graph_address=self._require_graph_address(),
)
runner.start()
self._sidecar = runner
self._compiled_pipelines = compiled_graphs
@staticmethod
def _subscriber_client_kwargs(ez_sub: EzSubscriber) -> dict[str, object]:
sub_kwargs: dict[str, object] = {}
if ez_sub.leaky:
sub_kwargs["leaky"] = True
if ez_sub.max_queue is not None:
sub_kwargs["max_queue"] = ez_sub.max_queue
return sub_kwargs
def _require_graph_address(self) -> AddressType:
address = self.graph_address
if address is None:
raise RuntimeError("Session graph address is not available")
return address
async def _create_subscriber_client(self, topic: str, **kwargs) -> Any:
return await EzSubscriberClient.create(
topic, self._require_graph_address(), **kwargs
)
async def _create_publisher_client(self, topic: str, **kwargs) -> Any:
return await EzPublisherClient.create(
topic, self._require_graph_address(), **kwargs
)
async def _setup_subscriber(self, ez_sub: EzSubscriber) -> None:
key = id(ez_sub)
if key in self._subscriber_runtime:
return
runtime = _SubscriberRuntime(switch_lock=asyncio.Lock())
self._subscriber_runtime[key] = runtime
try:
self._configure_subscriber_auto_gate(ez_sub, runtime)
if ez_sub._desired_topic is not None:
await self._switch_subscriber(ez_sub, ez_sub._desired_topic)
except Exception:
self._subscriber_runtime.pop(key, None)
if ez_sub.auto_gate:
self._dispatch(self._clear_visibility_gate, ez_sub)
raise
def _configure_subscriber_auto_gate(
self, ez_sub: EzSubscriber, runtime: _SubscriberRuntime
) -> None:
if not ez_sub.auto_gate:
runtime.gate_open = True
return
widget = ez_sub.parent_widget
if widget is None:
raise TypeError("EzSubscriber auto_gate requires a QWidget parent")
runtime.gate_open = widget.isVisible()
self._dispatch(self._setup_subscriber_auto_gate, ez_sub)
def _setup_subscriber_auto_gate(self, ez_sub: EzSubscriber) -> None:
widget = ez_sub.parent_widget
runtime = self._subscriber_runtime.get(id(ez_sub))
if widget is None or runtime is None:
return
visible = widget.isVisible()
if runtime.gate_open != visible:
if not visible:
ez_sub._emit_epoch += 1
runtime.gate_open = visible
subscriber_ref = weakref.ref(ez_sub)
def on_visibility(visible: bool) -> None:
current_sub = subscriber_ref()
if current_sub is None:
return
current_runtime = self._subscriber_runtime.get(id(current_sub))
if current_runtime is None or current_runtime.gate_open == visible:
return
if not visible:
current_sub._emit_epoch += 1
current_runtime.gate_open = visible
self._install_visibility_gate(ez_sub, widget, on_visibility)
def _install_visibility_gate(
self,
owner: object,
widget: QtWidgets.QWidget,
callback: Callable[[bool], None],
) -> None:
from .visibility import VisibilityFilter
self._clear_visibility_gate(owner)
event_filter = VisibilityFilter(callback, parent=widget)
widget.installEventFilter(event_filter)
setattr(owner, "_visibility_filter", event_filter)
def _clear_visibility_gate(self, owner: object) -> None:
event_filter = getattr(owner, "_visibility_filter", None)
if event_filter is None:
return
parent = event_filter.parent()
if isinstance(parent, QtWidgets.QWidget):
parent.removeEventFilter(event_filter)
event_filter.deleteLater()
setattr(owner, "_visibility_filter", None)
async def _switch_subscriber(self, ez_sub: EzSubscriber, topic) -> None:
key = id(ez_sub)
runtime = self._subscriber_runtime.get(key)
if runtime is None:
raise RuntimeError("Subscriber is not initialized on this session")
desired_topic = None if topic is None else normalize_topic(topic)
async with runtime.switch_lock:
ez_sub._emit_epoch += 1
if runtime.active_topic == desired_topic:
ez_sub._topic = topic
ez_sub._desired_topic = topic
return
await self._close_subscriber_client(runtime)
ez_sub._sub = None
try:
if desired_topic is not None:
client = await self._create_subscriber_client(
desired_topic, **self._subscriber_client_kwargs(ez_sub)
)
runtime.client = client
ez_sub._sub = client
runtime.task = self._start_subscriber_task(ez_sub, runtime)
except Exception:
runtime.active_topic = None
ez_sub._sub = None
ez_sub._topic = None
raise
runtime.active_topic = desired_topic
ez_sub._sub = runtime.client
ez_sub._topic = topic
ez_sub._desired_topic = topic
async def _setup_publisher(self, ez_pub: EzPublisher) -> None:
key = id(ez_pub)
if key in self._publisher_runtime:
return
if self._loop is None:
raise RuntimeError("Session loop is not initialized")
ez_pub._bind_runtime(self._loop)
runtime = _PublisherRuntime(
switch_lock=asyncio.Lock(),
)
self._publisher_runtime[key] = runtime
if ez_pub._desired_topic is not None:
await self._switch_publisher(ez_pub, ez_pub._desired_topic)
await ez_pub._flush_pending()
async def _switch_publisher(self, ez_pub: EzPublisher, topic) -> None:
key = id(ez_pub)
runtime = self._publisher_runtime.get(key)
if runtime is None:
raise RuntimeError("Publisher is not initialized on this session")
desired_topic = None if topic is None else normalize_topic(topic)
async with runtime.switch_lock:
if runtime.active_topic == desired_topic:
ez_pub._topic = topic
ez_pub._desired_topic = topic
return
await self._drain_publisher_queue(ez_pub)
await self._close_publisher_client(runtime)
ez_pub._pub = None
try:
if desired_topic is not None:
client = await self._create_publisher_client(desired_topic)
runtime.client = client
ez_pub._pub = client
runtime.task = self._start_publisher_task(ez_pub, runtime)
except Exception:
runtime.active_topic = None
ez_pub._pub = None
ez_pub._topic = None
raise
runtime.active_topic = desired_topic
ez_pub._pub = runtime.client
ez_pub._topic = topic
ez_pub._desired_topic = topic
async def _setup_pipeline_runtime(self, compiled: CompiledGraph) -> None:
key = id(compiled.graph)
sink_clients: list[Any] = []
sink_tasks: list[asyncio.Task[None]] = []
for sink in compiled.sinks:
client = await self._create_subscriber_client(sink.topic)
task = asyncio.create_task(
self._pipeline_output_loop(compiled, sink, client),
name=f"graph-{compiled.graph._graph_id}-{sink.sink_id}",
)
self._track_task(task)
sink_clients.append(client)
sink_tasks.append(task)
gate_publisher = None
graph = compiled.graph
if (
graph.auto_gate
and graph.parent_widget is not None
and compiled.gate_topic is not None
):
gate_publisher = await self._create_publisher_client(compiled.gate_topic)
await gate_publisher.broadcast(
GateMessage(open=graph.parent_widget.isVisible())
)
self._dispatch(self._setup_auto_gate, graph)
self._pipeline_runtime[key] = _PipelineRuntime(
compiled=compiled,
sink_clients=sink_clients,
sink_tasks=sink_tasks,
gate_publisher=gate_publisher,
)
def _setup_auto_gate(self, graph: "ProcessorGraph") -> None:
widget = graph.parent_widget
if widget is None:
return
graph_ref = weakref.ref(graph)
def on_visibility(visible: bool) -> None:
current_graph = graph_ref()
if current_graph is None or self._loop is None or not self._running:
return
asyncio.run_coroutine_threadsafe(
self._send_gate_message(current_graph, visible), self._loop
)
self._install_visibility_gate(graph, widget, on_visibility)
async def _send_gate_message(self, graph: "ProcessorGraph", open: bool) -> None:
runtime = self._pipeline_runtime.get(id(graph))
if runtime is None or runtime.gate_publisher is None:
return
await runtime.gate_publisher.broadcast(GateMessage(open=open))
async def _subscriber_loop(self, ez_sub: EzSubscriber, sub) -> None:
runtime = self._subscriber_runtime.get(id(ez_sub))
if runtime is None:
raise RuntimeError("Subscriber runtime is not initialized")
rate = None
if ez_sub.throttle_hz is not None:
from ezmsg.util.rate import Rate
rate = Rate(float(ez_sub.throttle_hz))
try:
while self._running:
epoch = ez_sub._emit_epoch
msg = await sub.recv()
if runtime.gate_open:
self._dispatch(ez_sub._on_message, msg, epoch)
if runtime.gate_open and rate is not None:
await rate.sleep()
except asyncio.CancelledError:
logger.debug("Subscriber loop cancelled for %s", ez_sub.topic)
except Exception:
logger.exception("Error in subscriber loop for %s", ez_sub.topic)
async def _publisher_loop(self, ez_pub: EzPublisher, pub) -> None:
try:
while self._running:
msg = await ez_pub._get_message()
await pub.broadcast(msg)
except asyncio.CancelledError:
logger.debug("Publisher loop cancelled for %s", ez_pub.topic)
except Exception:
logger.exception("Error in publisher loop for %s", ez_pub.topic)
async def _pipeline_output_loop(
self, compiled: CompiledGraph, sink: CompiledSink, sub
) -> None:
try:
while self._running:
msg = await sub.recv()
self._dispatch(sink.slot, msg)
except asyncio.CancelledError:
logger.debug("Graph loop cancelled for %s", compiled.graph._graph_id)
except Exception:
logger.exception("Error in graph loop for %s", compiled.graph._graph_id)
def _start_subscriber_task(
self, ez_sub: EzSubscriber, runtime: _SubscriberRuntime
) -> asyncio.Task[None]:
if runtime.client is None:
raise RuntimeError("Subscriber client is not initialized")
task = asyncio.create_task(
self._subscriber_loop(ez_sub, runtime.client),
name=f"sub-{runtime.active_topic or 'dynamic'}",
)
self._track_task(task)
return task
def _start_publisher_task(
self, ez_pub: EzPublisher, runtime: _PublisherRuntime
) -> asyncio.Task[None]:
if runtime.client is None:
raise RuntimeError("Publisher client is not initialized")
task = asyncio.create_task(
self._publisher_loop(ez_pub, runtime.client),
name=f"pub-{runtime.active_topic or 'dynamic'}",
)
self._track_task(task)
return task
async def _close_subscriber_client(self, runtime: _SubscriberRuntime) -> None:
if runtime.task is not None:
runtime.task.cancel()
await asyncio.gather(runtime.task, return_exceptions=True)
runtime.task = None
if runtime.client is not None:
client = runtime.client
runtime.client = None
client.close()
await client.wait_closed()
async def _close_publisher_client(self, runtime: _PublisherRuntime) -> None:
if runtime.task is not None:
runtime.task.cancel()
await asyncio.gather(runtime.task, return_exceptions=True)
runtime.task = None
if runtime.client is not None:
client = runtime.client
runtime.client = None
client.close()
await client.wait_closed()
async def _drain_publisher_queue(self, ez_pub: EzPublisher) -> None:
if ez_pub._async_queue is None:
return
while not ez_pub._async_queue.empty():
await asyncio.sleep(0.01)
def _track_task(self, task: asyncio.Task[None]) -> None:
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
@staticmethod
def _wait_for_runtime_operation(
future, operation: str, timeout: float = _RUNTIME_OPERATION_TIMEOUT
) -> None:
try:
future.result(timeout=timeout)
except FutureTimeoutError as exc:
future.cancel()
raise TimeoutError(f"Timed out waiting for {operation}") from exc
def _abort_startup(self) -> None:
self._running = False
self._shutdown.set()
if self._sidecar is not None:
try:
self._sidecar.stop()
except Exception:
logger.debug(
"Error stopping sidecar during startup abort", exc_info=True
)
finally:
self._sidecar = None
if self._thread is not None:
self._thread.join(timeout=5.0)
self._thread = None
self._restore_sigint_handler()
def _dispatch(self, func, *args) -> None:
if self._dispatcher is None:
self._dispatcher = _QtSignalDispatcher(QtWidgets.QApplication.instance())
self._dispatcher.schedule(func, *args)
async def _close_subscriber_runtime(
self, runtime: _SubscriberRuntime, subscriber: EzSubscriber | None = None
) -> None:
runtime.active_topic = None
await self._close_subscriber_client(runtime)
if subscriber is not None:
self._dispatch(self._clear_visibility_gate, subscriber)
subscriber._sub = None
subscriber._topic = None
subscriber._desired_topic = None
async def _close_publisher_runtime(
self, runtime: _PublisherRuntime, publisher: EzPublisher | None = None
) -> None:
runtime.active_topic = None
await self._close_publisher_client(runtime)
if publisher is not None:
publisher._pub = None
publisher._topic = None
publisher._desired_topic = None
async def _close_pipeline_runtime(self, runtime: _PipelineRuntime) -> None:
self._dispatch(self._clear_visibility_gate, runtime.compiled.graph)
for task in runtime.sink_tasks:
task.cancel()
if runtime.sink_tasks:
await asyncio.gather(*runtime.sink_tasks, return_exceptions=True)
for client in runtime.sink_clients:
client.close()
await client.wait_closed()
if runtime.gate_publisher is not None:
runtime.gate_publisher.close()
await runtime.gate_publisher.wait_closed()
async def _async_cleanup(self) -> None:
for runtime in list(self._subscriber_runtime.values()):
await self._close_subscriber_runtime(runtime)
self._subscriber_runtime.clear()
for runtime in list(self._publisher_runtime.values()):
await self._close_publisher_runtime(runtime)
self._publisher_runtime.clear()
for runtime in list(self._pipeline_runtime.values()):
await self._close_pipeline_runtime(runtime)
self._pipeline_runtime.clear()
if self._tasks:
for task in list(self._tasks):
task.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
if self._context_entered and self._context is not None:
await self._context.__aexit__(None, None, None)
self._context_entered = False
def _install_sigint_handler(self) -> None:
try:
self._prev_sigint_handler = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGINT, self._handle_sigint)
self._install_signal_wakeup()
except (ValueError, RuntimeError) as exc:
logger.debug("Unable to install SIGINT handler: %s", exc)
def _restore_sigint_handler(self) -> None:
self._restore_signal_wakeup()
if self._prev_sigint_handler is None:
return
try:
signal.signal(signal.SIGINT, self._prev_sigint_handler)
except (ValueError, RuntimeError) as exc:
logger.debug("Unable to restore SIGINT handler: %s", exc)
finally:
self._prev_sigint_handler = None
def _handle_sigint(self, _sig, _frame) -> None:
app = QtWidgets.QApplication.instance()
if app is not None:
app.quit()
def _install_signal_wakeup(self) -> None:
if self._sigint_notifier is not None:
return
try:
sock_r, sock_w = socket.socketpair()
sock_r.setblocking(False)
sock_w.setblocking(False)
self._wakeup_prev_fd = signal.set_wakeup_fd(sock_w.fileno())
self._wakeup_sock_r = sock_r
self._wakeup_sock_w = sock_w
app = QtWidgets.QApplication.instance()
self._sigint_notifier = QtCore.QSocketNotifier(
sock_r.fileno(), # pyright: ignore[reportArgumentType]
QtCore.QSocketNotifier.Type.Read,
app,
)
self._sigint_notifier.activated.connect(self._on_signal_wakeup)
except (OSError, RuntimeError, ValueError) as exc:
logger.debug("Unable to install signal wakeup fd: %s", exc)
self._restore_signal_wakeup()
def _restore_signal_wakeup(self) -> None:
if self._sigint_notifier is not None:
self._sigint_notifier.setEnabled(False)
self._sigint_notifier.deleteLater()
self._sigint_notifier = None
if self._wakeup_prev_fd is not None:
try:
signal.set_wakeup_fd(self._wakeup_prev_fd)
except (OSError, RuntimeError, ValueError) as exc:
logger.debug("Unable to restore wakeup fd: %s", exc)
finally:
self._wakeup_prev_fd = None
if self._wakeup_sock_r is not None:
self._wakeup_sock_r.close()
self._wakeup_sock_r = None
if self._wakeup_sock_w is not None:
self._wakeup_sock_w.close()
self._wakeup_sock_w = None
def _on_signal_wakeup(self, _fd: int) -> None:
if self._wakeup_sock_r is None:
return
got_sigint = False
try:
while True:
data = self._wakeup_sock_r.recv(128)
if not data:
break
if signal.SIGINT in data:
got_sigint = True
except BlockingIOError:
pass
if got_sigint:
self._handle_sigint(signal.SIGINT, None)