Add structured `QtractorSession` lifecycle
Factor nonblocking `start_qtractor()` from the blocking Qt-loop adapter and expose guest readiness, completion, outcome, and owned cancellation through `QtractorSession`. Replace `MainWindow.closeEvent()` process-wide `SIGINT` with the session shutdown callback. Publish readiness after `_async_main()` enters all startup scopes, then restore shared Qt state and globals after guest completion. Add a headless regression which starts and closes two real Tractor runtimes under one pytest-owned `QApplication`. Prompt-IO: ai/prompt-io/opencode/20260903T205404Z_aadfd1cf_prompt_io.md (this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))wkt/live_chart_qtractor
parent
aadfd1cfcc
commit
1a2d489397
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
model: openai/gpt-5.6-sol
|
||||
service: opencode
|
||||
session: ses_faae5c497ffeS0ofaTEfbRo2LP
|
||||
timestamp: 2026-09-03T20:54:04Z
|
||||
git_ref: aadfd1cf
|
||||
scope: code
|
||||
substantive: true
|
||||
raw_file: 20260903T205404Z_aadfd1cf_prompt_io.raw.md
|
||||
---
|
||||
|
||||
## Prompt
|
||||
|
||||
Continue from the completed replay handoff when next steps are clear.
|
||||
Honor the prior decision to park replay and begin the live-backend-first
|
||||
chart E2E roadmap with the Phase A Qt/Trio lifecycle seam. First perform
|
||||
the approved exhaustive local scan, then create
|
||||
`wkt/live_chart_qtractor` from exact Phase 1 commit `aadfd1cf` and
|
||||
implement nonblocking startup, structured shutdown, observable readiness
|
||||
and completion, and provider-independent lifecycle coverage without new
|
||||
interlayer mocks.
|
||||
|
||||
## Response summary
|
||||
|
||||
Creates the isolated worktree after classifying two stale historical
|
||||
lifecycle experiments as related but not reusable. Extracts
|
||||
`start_qtractor()` and `QtractorSession`, replaces process-wide SIGINT on
|
||||
window close with owned Trio cancellation, publishes application
|
||||
readiness at the full `_async_main()` startup boundary, and preserves
|
||||
`run_qtractor()` as the blocking Qt-loop owner which unwraps the guest
|
||||
outcome. A headless regression proves two sequential real Tractor
|
||||
runtimes start and stop cleanly under one pytest-owned Qt application.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `piker/ui/_exec.py` - add nonblocking session startup, lifecycle
|
||||
signals, owned cancellation, outcome capture, Qt cleanup, and the
|
||||
blocking CLI adapter.
|
||||
- `piker/ui/_window.py` - inject structured shutdown and remove normal
|
||||
window-close SIGINT while cleaning the global zoom filter.
|
||||
- `piker/ui/_app.py` - publish readiness after all application startup
|
||||
scopes are live.
|
||||
- `tests/ui/test_qtractor.py` - prove repeated real-Tractor lifecycle
|
||||
startup and close under pytest-qt.
|
||||
|
||||
## Human edits
|
||||
|
||||
The human chose exhaustive local discovery specifically to catch prior
|
||||
Qt/Trio experiments, confirmed the replay branch had been pushed to
|
||||
Gitea for backup, and directed continuation without another planning
|
||||
pause. The inherited human-approved roadmap parks replay, prioritizes a
|
||||
real Kraken chart, forbids additional interlayer mocks without a concrete
|
||||
regression, and fixes `aadfd1cf` as this worktree's base. The human also
|
||||
explicitly authorized the focused headless test through `nix develop`;
|
||||
no real-compositor run was authorized or performed. During final review,
|
||||
the human corrected nonconforming boolean layout, requested local type
|
||||
annotations wherever helper return types were not obvious at the binding,
|
||||
and replaced silent Qt disconnect suppression with Piker-style exception
|
||||
logging.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
model: openai/gpt-5.6-sol
|
||||
service: opencode
|
||||
timestamp: 2026-09-03T20:54:04Z
|
||||
git_ref: aadfd1cf
|
||||
diff_cmd: git diff HEAD
|
||||
---
|
||||
|
||||
## Generated changes
|
||||
|
||||
> `git diff HEAD -- piker/ui/_exec.py piker/ui/_window.py`
|
||||
|
||||
Extracts nonblocking `start_qtractor()` from the blocking CLI adapter.
|
||||
The returned `QtractorSession` exposes the production window and central
|
||||
widget, the Trio token and cancellation scope, readiness and completion
|
||||
Qt signals, the final `outcome.Outcome`, Qt-loop ownership, and an
|
||||
idempotent structured shutdown request. Caller-owned Qt application
|
||||
state and installed callbacks are restored when the guest completes.
|
||||
`MainWindow.closeEvent()` retains geometry persistence but delegates
|
||||
runtime teardown through the injected callback instead of sending
|
||||
process-wide SIGINT.
|
||||
|
||||
> `git diff HEAD -- piker/ui/_app.py`
|
||||
|
||||
Publishes application readiness only after initial symbol loading,
|
||||
order-mode startup, search registration, provider-search task startup,
|
||||
input handler entry, and startup-status removal.
|
||||
|
||||
> `git diff HEAD -- tests/ui/test_qtractor.py`
|
||||
|
||||
Adds a provider-independent lifecycle regression which runs two real
|
||||
Tractor root runtimes in sequence on pytest's unique registry. Real
|
||||
window-close events drive structured cancellation while signal, outcome,
|
||||
task-finalization, global-window, runtime, and shared-QApplication
|
||||
assertions prove restartable teardown without an interlayer fake.
|
||||
|
||||
Human review then brought the generated Python into project style:
|
||||
boolean connectives use dedicated vertical lines, opaque helper returns
|
||||
and control-flow targets carry local annotations, and Qt disconnect
|
||||
failures are logged instead of silently suppressed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Ruff passes for all four changed Python files.
|
||||
- Python static compilation and `git diff --check` pass.
|
||||
- The first focused run exposed the test widget's missing production
|
||||
`on_win_resize()` contract; the narrow test widget was corrected.
|
||||
- The focused lifecycle regression passes with two sequential real
|
||||
Tractor sessions.
|
||||
- The combined UI harness and lifecycle run passes: 3 tests and two
|
||||
upstream Tractor `Registrar.uid` deprecation warnings.
|
||||
- A post-pass attempt to disconnect a pre-existing Qt signal proxy
|
||||
segfaulted during PyQt teardown and was fully reverted; it is not part
|
||||
of this patch. The restored lifecycle regression passes again.
|
||||
- All automated Qt execution used `--headless` and the offscreen
|
||||
platform.
|
||||
- The approved `nix develop` shell hook unexpectedly provisioned the
|
||||
ignored worktree-local `py313/` environment; no tracked dependency or
|
||||
lock files changed.
|
||||
|
|
@ -38,7 +38,10 @@ from ..log import (
|
|||
get_console_log,
|
||||
)
|
||||
from ..service import maybe_spawn_datad
|
||||
from ._exec import run_qtractor
|
||||
from ._exec import (
|
||||
QtractorSession,
|
||||
run_qtractor,
|
||||
)
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
|
@ -194,6 +197,14 @@ async def _async_main(
|
|||
):
|
||||
# remove startup status text
|
||||
starting_done()
|
||||
qtractor_session: QtractorSession|None = (
|
||||
godwidget.window.qtractor_session
|
||||
)
|
||||
if qtractor_session is None:
|
||||
raise RuntimeError(
|
||||
'No Qtractor session owns the main window'
|
||||
)
|
||||
qtractor_session.set_ready()
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,24 +22,22 @@ All global Qt runtime settings are mostly defined here.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
from typing import (
|
||||
Callable,
|
||||
Any,
|
||||
Type,
|
||||
Awaitable,
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
import platform
|
||||
import traceback
|
||||
|
||||
import qdarkstyle
|
||||
from qdarkstyle import DarkPalette
|
||||
# import qdarkgraystyle # TODO: play with it
|
||||
import trio
|
||||
from outcome import Error
|
||||
from outcome import Outcome
|
||||
|
||||
# Qt version-agnostic
|
||||
from .qt import (
|
||||
QWidget,
|
||||
QMainWindow,
|
||||
QApplication,
|
||||
QtCore,
|
||||
pyqtRemoveInputHook,
|
||||
|
|
@ -56,6 +54,7 @@ from . import _style
|
|||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._window import MainWindow
|
||||
from ._widget import GodWidget
|
||||
|
||||
|
||||
|
|
@ -70,7 +69,7 @@ _do_overrides()
|
|||
# XXX: pretty sure none of this shit works on linux as per:
|
||||
# https://bugreports.qt.io/browse/QTBUG-53022
|
||||
# it seems to work on windows.. no idea wtf is up.
|
||||
is_windows = False
|
||||
is_windows: bool = False
|
||||
if platform.system() == "Windows":
|
||||
is_windows = True
|
||||
|
||||
|
|
@ -83,24 +82,131 @@ if platform.system() == "Windows":
|
|||
QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||
|
||||
|
||||
def run_qtractor(
|
||||
func: Callable,
|
||||
args: tuple,
|
||||
main_widget_type: Type[QWidget],
|
||||
tractor_kwargs: dict[str, Any] = {},
|
||||
window_type: QMainWindow = None,
|
||||
|
||||
) -> None:
|
||||
class QtractorSession(QtCore.QObject):
|
||||
'''
|
||||
Run the Qt event loop and embed `trio` via guest mode on it.
|
||||
Own one Trio guest run embedded in a Qt application.
|
||||
|
||||
'''
|
||||
application_ready = QtCore.pyqtSignal()
|
||||
completed = QtCore.pyqtSignal(object)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: QApplication,
|
||||
window: MainWindow,
|
||||
main_widget: QWidget,
|
||||
owns_app_exec: bool,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Initialize state owned by one Qt-hosted Trio guest run.
|
||||
|
||||
'''
|
||||
super().__init__()
|
||||
self.app = app
|
||||
self.window = window
|
||||
self.main_widget = main_widget
|
||||
self.owns_app_exec = owns_app_exec
|
||||
self.trio_token: trio.lowlevel.TrioToken|None = None
|
||||
self.cancel_scope: trio.CancelScope|None = None
|
||||
self.outcome: Outcome[Any]|None = None
|
||||
self.is_ready: bool = False
|
||||
self._shutdown_requested: bool = False
|
||||
self._shutdown_scheduled: bool = False
|
||||
|
||||
def set_ready(self) -> None:
|
||||
'''
|
||||
Publish that the application entered all startup scopes.
|
||||
|
||||
'''
|
||||
if self.is_ready:
|
||||
return
|
||||
|
||||
self.is_ready = True
|
||||
self.application_ready.emit()
|
||||
|
||||
def request_shutdown(self) -> None:
|
||||
'''
|
||||
Cancel the owned Trio guest run through its host token.
|
||||
|
||||
'''
|
||||
if self.outcome is not None:
|
||||
return
|
||||
|
||||
self._shutdown_requested = True
|
||||
token: trio.lowlevel.TrioToken|None = self.trio_token
|
||||
cancel_scope: trio.CancelScope|None = self.cancel_scope
|
||||
if (
|
||||
token is None
|
||||
or
|
||||
cancel_scope is None
|
||||
or
|
||||
self._shutdown_scheduled
|
||||
):
|
||||
return
|
||||
|
||||
self._shutdown_scheduled = True
|
||||
try:
|
||||
token.run_sync_soon(cancel_scope.cancel)
|
||||
except trio.RunFinishedError:
|
||||
log.debug(
|
||||
'Trio guest run already finished before shutdown'
|
||||
)
|
||||
|
||||
def _guest_started(
|
||||
self,
|
||||
token: trio.lowlevel.TrioToken,
|
||||
cancel_scope: trio.CancelScope,
|
||||
|
||||
) -> None:
|
||||
self.trio_token = token
|
||||
self.cancel_scope = cancel_scope
|
||||
if self._shutdown_requested:
|
||||
cancel_scope.cancel()
|
||||
|
||||
def _guest_completed(
|
||||
self,
|
||||
final_outcome: Outcome[Any],
|
||||
|
||||
) -> None:
|
||||
self.outcome = final_outcome
|
||||
self.completed.emit(final_outcome)
|
||||
|
||||
|
||||
def start_qtractor(
|
||||
func: Callable[..., Awaitable[Any]],
|
||||
args: tuple[Any, ...],
|
||||
main_widget_type: type[QWidget],
|
||||
tractor_kwargs: dict[str, Any]|None = None,
|
||||
window_type: type[MainWindow]|None = None,
|
||||
app: QApplication|None = None,
|
||||
owns_app_exec: bool = False,
|
||||
|
||||
) -> QtractorSession:
|
||||
'''
|
||||
Start a Trio guest run without entering Qt's event loop.
|
||||
|
||||
'''
|
||||
# avoids annoying message when entering debugger from qt loop
|
||||
pyqtRemoveInputHook()
|
||||
|
||||
app = QApplication.instance()
|
||||
running_app: QApplication|None = QApplication.instance()
|
||||
if app is None:
|
||||
app = QApplication([])
|
||||
app = running_app or QApplication([])
|
||||
elif (
|
||||
running_app is not None
|
||||
and
|
||||
running_app is not app
|
||||
):
|
||||
raise RuntimeError(
|
||||
'The requested QApplication is not the active instance'
|
||||
)
|
||||
|
||||
prior_quit_on_last_window: bool = (
|
||||
app.quitOnLastWindowClosed()
|
||||
)
|
||||
prior_cursor_flash_time: int = app.cursorFlashTime()
|
||||
prior_stylesheet: str = app.styleSheet()
|
||||
|
||||
# TODO: we might not need this if it's desired
|
||||
# to cancel the tractor machinery on Qt loop
|
||||
|
|
@ -118,39 +224,58 @@ def run_qtractor(
|
|||
|
||||
# source gist and credit to njs:
|
||||
# https://gist.github.com/njsmith/d996e80b700a339e0623f97f48bcf0cb
|
||||
REENTER_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType())
|
||||
REENTER_EVENT: QtCore.QEvent.Type = QtCore.QEvent.Type(
|
||||
QtCore.QEvent.registerEventType()
|
||||
)
|
||||
|
||||
class ReenterEvent(QtCore.QEvent):
|
||||
pass
|
||||
'''
|
||||
Carry one Trio guest callback through Qt's event queue.
|
||||
|
||||
'''
|
||||
def __init__(
|
||||
self,
|
||||
fn: Callable[[], None],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Initialize the event with its scheduled callback.
|
||||
|
||||
'''
|
||||
super().__init__(REENTER_EVENT)
|
||||
self.fn: Callable[[], None] = fn
|
||||
|
||||
class Reenter(QtCore.QObject):
|
||||
def event(self, event):
|
||||
'''
|
||||
Dispatch Trio re-entry events received from Qt.
|
||||
|
||||
'''
|
||||
def event(self, event: QtCore.QEvent) -> bool:
|
||||
'''
|
||||
Run a scheduled callback or defer another event to Qt.
|
||||
|
||||
'''
|
||||
if not isinstance(event, ReenterEvent):
|
||||
return super().event(event)
|
||||
|
||||
event.fn()
|
||||
return False
|
||||
|
||||
reenter = Reenter()
|
||||
reenter: Reenter = Reenter()
|
||||
|
||||
def run_sync_soon_threadsafe(fn):
|
||||
event = ReenterEvent(REENTER_EVENT)
|
||||
event.fn = fn
|
||||
def run_sync_soon_threadsafe(
|
||||
fn: Callable[[], None],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Post one Trio guest callback onto Qt's event queue.
|
||||
|
||||
'''
|
||||
event: ReenterEvent = ReenterEvent(fn)
|
||||
app.postEvent(reenter, event)
|
||||
|
||||
def done_callback(outcome):
|
||||
|
||||
if isinstance(outcome, Error):
|
||||
exc = outcome.error
|
||||
|
||||
if isinstance(outcome.error, KeyboardInterrupt):
|
||||
# make it kinda look like ``trio``
|
||||
print("Terminated!")
|
||||
|
||||
else:
|
||||
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
||||
|
||||
app.quit()
|
||||
|
||||
# load dark theme
|
||||
stylesheet = qdarkstyle.load_stylesheet(
|
||||
stylesheet: str = qdarkstyle.load_stylesheet(
|
||||
qt_api='pyqt6',
|
||||
palette=DarkPalette,
|
||||
)
|
||||
|
|
@ -162,7 +287,7 @@ def run_qtractor(
|
|||
if window_type is None:
|
||||
window_type = _window.MainWindow
|
||||
|
||||
window = window_type()
|
||||
window: MainWindow = window_type()
|
||||
|
||||
# set global app's main window singleton
|
||||
_window._qt_win = window
|
||||
|
|
@ -177,19 +302,66 @@ def run_qtractor(
|
|||
instance: GodWidget = main_widget_type()
|
||||
instance.window = window
|
||||
|
||||
session = QtractorSession(
|
||||
app=app,
|
||||
window=window,
|
||||
main_widget=instance,
|
||||
owns_app_exec=owns_app_exec,
|
||||
)
|
||||
window.qtractor_session = session
|
||||
window.set_shutdown_callback(session.request_shutdown)
|
||||
|
||||
# override tractor's defaults
|
||||
tractor_kwargs = dict(tractor_kwargs or {})
|
||||
tractor_kwargs.update(
|
||||
get_runtime_vars()
|
||||
)
|
||||
|
||||
# define tractor entrypoint
|
||||
async def main():
|
||||
async def main() -> None:
|
||||
'''
|
||||
Run the application under its owned Trio cancel scope.
|
||||
|
||||
'''
|
||||
cancel_scope: trio.CancelScope
|
||||
with trio.CancelScope() as cancel_scope:
|
||||
session._guest_started(
|
||||
trio.lowlevel.current_trio_token(),
|
||||
cancel_scope,
|
||||
)
|
||||
async with maybe_open_pikerd(
|
||||
**tractor_kwargs,
|
||||
):
|
||||
await func(*((instance,) + args))
|
||||
|
||||
def done_callback(final_outcome: Outcome[Any]) -> None:
|
||||
'''
|
||||
Restore Qt host state and publish guest completion.
|
||||
|
||||
'''
|
||||
try:
|
||||
app.focusChanged.disconnect(window.on_focus_change)
|
||||
except (
|
||||
TypeError,
|
||||
RuntimeError,
|
||||
):
|
||||
log.exception(
|
||||
'Failed to disconnect '
|
||||
'`MainWindow.on_focus_change()`'
|
||||
)
|
||||
window.uninstall_global_zoom_filter()
|
||||
window.set_shutdown_callback(None)
|
||||
window.qtractor_session = None
|
||||
if _window._qt_win is window:
|
||||
_window._qt_win = None
|
||||
if not owns_app_exec:
|
||||
app.setQuitOnLastWindowClosed(
|
||||
prior_quit_on_last_window
|
||||
)
|
||||
app.setCursorFlashTime(prior_cursor_flash_time)
|
||||
app.setStyleSheet(prior_stylesheet)
|
||||
session._guest_completed(final_outcome)
|
||||
|
||||
# guest mode entry
|
||||
trio.lowlevel.start_guest_run(
|
||||
main,
|
||||
|
|
@ -208,4 +380,46 @@ def run_qtractor(
|
|||
|
||||
# actually render to screen
|
||||
window.show()
|
||||
app.exec_()
|
||||
return session
|
||||
|
||||
|
||||
def run_qtractor(
|
||||
func: Callable[..., Awaitable[Any]],
|
||||
args: tuple[Any, ...],
|
||||
main_widget_type: type[QWidget],
|
||||
tractor_kwargs: dict[str, Any]|None = None,
|
||||
window_type: type[MainWindow]|None = None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Run Qt and unwrap the embedded Trio guest run's result.
|
||||
|
||||
'''
|
||||
session: QtractorSession = start_qtractor(
|
||||
func=func,
|
||||
args=args,
|
||||
main_widget_type=main_widget_type,
|
||||
tractor_kwargs=tractor_kwargs,
|
||||
window_type=window_type,
|
||||
owns_app_exec=True,
|
||||
)
|
||||
session.completed.connect(session.app.quit)
|
||||
try:
|
||||
session.app.exec_()
|
||||
finally:
|
||||
try:
|
||||
session.completed.disconnect(session.app.quit)
|
||||
except (
|
||||
TypeError,
|
||||
RuntimeError,
|
||||
):
|
||||
log.exception(
|
||||
'Failed to disconnect `QApplication.quit()`'
|
||||
)
|
||||
|
||||
final_outcome: Outcome[Any]|None = session.outcome
|
||||
if final_outcome is None:
|
||||
raise RuntimeError(
|
||||
'Qt exited before the Trio guest run completed'
|
||||
)
|
||||
final_outcome.unwrap()
|
||||
|
|
|
|||
|
|
@ -19,11 +19,10 @@ Qt main window singletons and stuff.
|
|||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from typing import (
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
)
|
||||
import uuid
|
||||
|
|
@ -51,6 +50,10 @@ from ._style import (
|
|||
from ._widget import GodWidget
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._exec import QtractorSession
|
||||
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
|
|
@ -291,15 +294,45 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# event filter for global zoom shortcuts
|
||||
self._zoom_filter: GlobalZoomEventFilter | None = None
|
||||
self._shutdown_callback: Callable[[], None]|None = None
|
||||
self.qtractor_session: QtractorSession|None = None
|
||||
|
||||
def install_global_zoom_filter(self) -> None:
|
||||
'''Install application-level event filter for global UI zoom shortcuts.'''
|
||||
if self._zoom_filter is None:
|
||||
self._zoom_filter = GlobalZoomEventFilter(self)
|
||||
app = QApplication.instance()
|
||||
app: QApplication|None = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError(
|
||||
'Cannot install zoom filter without a QApplication'
|
||||
)
|
||||
app.installEventFilter(self._zoom_filter)
|
||||
log.info('Installed global zoom shortcuts: Ctrl+Shift+Plus/Minus/0')
|
||||
|
||||
def uninstall_global_zoom_filter(self) -> None:
|
||||
'''
|
||||
Remove this window's application-level zoom filter.
|
||||
|
||||
'''
|
||||
if self._zoom_filter is None:
|
||||
return
|
||||
|
||||
app: QApplication|None = QApplication.instance()
|
||||
if app is not None:
|
||||
app.removeEventFilter(self._zoom_filter)
|
||||
self._zoom_filter = None
|
||||
|
||||
def set_shutdown_callback(
|
||||
self,
|
||||
callback: Callable[[], None]|None,
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Set the structured runtime-shutdown request callback.
|
||||
|
||||
'''
|
||||
self._shutdown_callback = callback
|
||||
|
||||
@property
|
||||
def mode_label(self) -> QLabel:
|
||||
|
||||
|
|
@ -341,11 +374,10 @@ class MainWindow(QMainWindow):
|
|||
settings.setValue('windowGeometry', self.saveGeometry())
|
||||
log.debug('Saved window geometry for next session')
|
||||
|
||||
# raising KBI seems to get intercepted by by Qt so just use the system.
|
||||
os.kill(
|
||||
os.getpid(),
|
||||
signal.SIGINT,
|
||||
)
|
||||
if self._shutdown_callback is not None:
|
||||
self._shutdown_callback()
|
||||
|
||||
super().closeEvent(event)
|
||||
|
||||
@property
|
||||
def status_bar(self) -> QStatusBar:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from PyQt6.QtGui import QResizeEvent
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
)
|
||||
from pytestqt.qtbot import QtBot
|
||||
from pytestqt.wait_signal import SignalBlocker
|
||||
import tractor
|
||||
import trio
|
||||
|
||||
from piker.ui import _window
|
||||
from piker.ui._exec import (
|
||||
QtractorSession,
|
||||
start_qtractor,
|
||||
)
|
||||
from piker.ui._window import MainWindow
|
||||
|
||||
|
||||
class QtractorTestWidget(QWidget):
|
||||
'''
|
||||
Supply the central-widget resize hook expected by `MainWindow`.
|
||||
|
||||
'''
|
||||
window: MainWindow
|
||||
|
||||
def on_win_resize(self, event: QResizeEvent) -> None:
|
||||
'''
|
||||
Accept production `MainWindow.resizeEvent()` notifications.
|
||||
|
||||
'''
|
||||
|
||||
|
||||
async def run_until_window_close(
|
||||
main_widget: QtractorTestWidget,
|
||||
exited: list[QtractorTestWidget],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Publish readiness and retain the guest task until cancellation.
|
||||
|
||||
'''
|
||||
session: QtractorSession|None = (
|
||||
main_widget.window.qtractor_session
|
||||
)
|
||||
if session is None:
|
||||
raise RuntimeError(
|
||||
'No Qtractor session owns the test window'
|
||||
)
|
||||
session.set_ready()
|
||||
try:
|
||||
await trio.sleep_forever()
|
||||
finally:
|
||||
exited.append(main_widget)
|
||||
|
||||
|
||||
def test_qtractor_restarts_after_structured_window_close(
|
||||
isolated_ui_state: SimpleNamespace,
|
||||
qapp: QApplication,
|
||||
qtbot: QtBot,
|
||||
reg_addr: tuple[str, int],
|
||||
|
||||
) -> None:
|
||||
'''
|
||||
Repeated Qt sessions must not signal or strand the Trio runtime.
|
||||
|
||||
`MainWindow.closeEvent()` previously sent process-wide SIGINT
|
||||
while `run_qtractor()` hid the guest outcome behind blocking
|
||||
`app.exec_()`. A caller-owned pytest-qt application therefore
|
||||
could not observe a clean startup or close one session without
|
||||
interrupting pytest.
|
||||
|
||||
Start two real Tractor root runtimes in sequence on the
|
||||
test-owned registry. Each guest publishes readiness only after
|
||||
entering its task, then a real window-close event requests
|
||||
cancellation through `QtractorSession`. Completion, the guest
|
||||
`finally` block, the empty runtime slot, and successful second
|
||||
startup prove structured teardown completed without quitting the
|
||||
shared Qt application.
|
||||
|
||||
'''
|
||||
assert isolated_ui_state.qapp is qapp
|
||||
exited: list[QtractorTestWidget] = []
|
||||
qt_state: tuple[bool, int, str] = (
|
||||
qapp.quitOnLastWindowClosed(),
|
||||
qapp.cursorFlashTime(),
|
||||
qapp.styleSheet(),
|
||||
)
|
||||
tractor_kwargs: dict[str, Any] = {
|
||||
'registry_addrs': [reg_addr],
|
||||
'tractor_runtime_overrides': {
|
||||
'piker_test_dir': str(
|
||||
isolated_ui_state.test_config_dir
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
_: int
|
||||
for _ in range(2):
|
||||
session: QtractorSession = start_qtractor(
|
||||
func=run_until_window_close,
|
||||
args=(exited,),
|
||||
main_widget_type=QtractorTestWidget,
|
||||
tractor_kwargs=tractor_kwargs,
|
||||
app=qapp,
|
||||
)
|
||||
qtbot.addWidget(session.window)
|
||||
|
||||
assert session.app is qapp
|
||||
assert not session.owns_app_exec
|
||||
assert session.window.godwidget is session.main_widget
|
||||
assert session.window.isVisible()
|
||||
assert session.outcome is None
|
||||
|
||||
ready: SignalBlocker = qtbot.waitSignal(
|
||||
session.application_ready,
|
||||
timeout=30_000,
|
||||
)
|
||||
ready.wait()
|
||||
assert session.is_ready
|
||||
assert session.trio_token is not None
|
||||
assert session.cancel_scope is not None
|
||||
|
||||
completed: SignalBlocker
|
||||
with qtbot.waitSignal(
|
||||
session.completed,
|
||||
timeout=30_000,
|
||||
) as completed:
|
||||
session.window.close()
|
||||
|
||||
assert completed.args == [session.outcome]
|
||||
assert session.outcome is not None
|
||||
assert session.outcome.unwrap() is None
|
||||
assert session.cancel_scope.cancel_called
|
||||
assert exited[-1] is session.main_widget
|
||||
assert session.window.qtractor_session is None
|
||||
assert _window._qt_win is None
|
||||
assert tractor.current_actor(
|
||||
err_on_no_runtime=False,
|
||||
) is None
|
||||
assert not qapp.closingDown()
|
||||
assert (
|
||||
qapp.quitOnLastWindowClosed(),
|
||||
qapp.cursorFlashTime(),
|
||||
qapp.styleSheet(),
|
||||
) == qt_state
|
||||
Loading…
Reference in New Issue