Compare commits

...

3 Commits

Author SHA1 Message Date
Gud Boi 4ab49082c1 Add opt-in Kraken chart lifecycle reproducer
Preserve the production chart journey behind
`--live-provider=kraken`; ordinary runs skip it without network
access. Public Kraken data needs no API key. Supply empty owned
broker config, a unique registry and explicit paper EMS without
submitting orders.

Observe readiness or guest failure, retain original errors, and
check observed actor endpoints and chart SHM after cancellation.
These observations do not prove all descendant PIDs have exited
or account for unpublished child SHM.

The selected live run is known-failing: readiness can precede
`rt_linked.mode`, Qt reports abstract paint calls, and teardown
left a paper actor requiring explicit SIGINT recovery. Preserve
this reproducer for the follow-up; do not treat its default skip
as successful live qualification.

Generation and failure evidence were recorded with the preceding
prerequisite commit in the existing session provenance.

Prompt-IO: ai/prompt-io/opencode/20260908T215045Z_1a2d4893_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-6-astra` (`openai`))
2026-10-20 11:23:30 -04:00
Gud Boi 5c17419c93 Fix `Qtractor` startup and paper-chart prerequisites
Let explicit runtime kwargs override stale globals and drive the
blocking adapter through PyQt6's `QApplication.exec()`. Exercise
registry selection and normal/error completion with real runtimes.

Thread an explicit paper execution mode through chart startup and
subsequent symbol loads to EMS, retaining the live CLI default.
Reject invalid modes before initialization and await provider search
registration before publishing application readiness.

Also, correct the credential-free Kraken `Client(config={})` call.
The public-data run progressed past this constructor failure but
still exposed display readiness, Qt paint, and daemon teardown
defects. This is a prerequisite fix, not live-chart qualification;
the failing opt-in reproducer remains outside this boundary.

Retain the combined session provenance, including failed live-run
evidence. Its orphan report predates the later human-authorized
SIGINT cleanup, which stopped the exact remaining paper actor.

Prompt-IO: ai/prompt-io/opencode/20260908T215045Z_1a2d4893_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-6-astra` (`openai`))
2026-10-20 11:14:03 -04:00
Gud Boi 1a2d489397 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`))
2026-09-08 11:21:28 -04:00
15 changed files with 1432 additions and 65 deletions

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,179 @@
---
model: openai/gpt-6-astra
service: opencode
session: null
timestamp: 2026-09-08T21:50:45Z
git_ref: 1a2d4893
scope: code
substantive: true
raw_file: 20260908T215045Z_1a2d4893_prompt_io.raw.md
---
## Prompt
Continue implementation in SAME session/worktree
/home/goodboy/repos/piker/wkts/live_chart_qtractor. User explicitly
says proceed. No ownership takeover needed, no branch creation, no
staging/commits, no ai.skillz changes. Implement minimal prerequisite
fixes before live Kraken tests: inspect current code and fix
start_qtractor merge precedence so explicit caller registry/runtime
kwargs beat global defaults; fix PyQt6 blocking run_qtractor exec_ ->
exec; add focused offline headless lifecycle regressions proving
explicit registry selection despite stale globals and CLI adapter as
feasible. Load py-codestyle, piker-conc-expert, run-tests via tools;
read deployed test harness. Preserve existing comments and user-owned
state. Use apply_patch. Existing staged baseline is committed at
1a2d4893, .gitignore/.ai workflow state unrelated leave untouched. User
prefers typed opaque function returns, no redundant obvious
constructor types, vertical boolean operators, logging exceptions.
Run only targeted headless tests with existing py313 interpreter and
Nix wrapper (already user-authorized in this session); do not run full
suite or public network charts. Do not expand to paper mode or
readiness yet. Return changes/tests, exact remaining blockers, and any
provenance modifications. Persist prompt-io for code per skill with
correct active model identity if known, not invented old model. Keep
minimal.
## Response Summary
Fixed explicit caller kwarg precedence and the PyQt6 blocking API.
Extended real runtime lifecycle coverage and added subprocess-isolated
CLI adapter success/error regressions. Focused tests passed 3/3;
combined UI isolation and lifecycle tests passed 5/5, with only two
existing Tractor deprecation warnings per run.
## Files Changed
- `piker/ui/_exec.py`: merge order and `QApplication.exec()`.
- `tests/ui/test_qtractor.py`: stale-global and blocking regressions.
- This log and its raw companion: prompt and generated-output record.
Existing provenance entries and the service README are unchanged.
Unrelated `.gitignore` and `.ai` state are unchanged. No staging,
commits, branch creation, or ai.skillz changes were performed.
The active harness did not expose a session UUID; it is not invented.
## Human Edits
The human identified both prerequisite defects, selected this existing
worktree, restricted implementation to minimal offline coverage, and
specified code style, test environment, provenance identity, and
untouched state. These are human-directed agent edits. No direct
manual edits during this turn were observed; acceptance remains with
the human.
## Continuation Prompt: 2026-09-08T21:56:56Z
Implement next minimal safety prerequisite in
/home/goodboy/repos/piker/wkts/live_chart_qtractor. Same session user
authorized continuation. Previous agent modified piker/ui/_exec.py,
tests/ui/test_qtractor.py and added
ai/prompt-io/opencode/20260908T215045Z_1a2d4893_prompt_io.md/.raw.md for
registry precedence and Qt exec fixes. Preserve all those and unrelated
.gitignore/.ai files. No staging, commits, branch/worktree operations
or ai.skillz edits. Task: inspect production chart startup
_app._async_main -> GodWidget/load_symbols/display -> open_order_mode
-> open_ems. Thread a minimal explicit execution mode option so a chart
integration caller can require paper EMS BEFORE any brokerd spawn.
Preserve shipped default live CLI behavior (concrete existing callers).
Prefer one clear existing convention/type and defaults, no
compatibility scaffolding. Also inspect readiness provider search
start_soon: implement minimal nursery.start/task_status handshake if
appropriate to ensure application readiness follows provider search
registration, without arbitrary waits. Load piker-clearing-expert,
piker-conc-expert, py-codestyle, run-tests skills and harness. Tests
must be real structured runtime tests or narrow direct unit tests as
existing conventions permit; do not add interlayer mocking without
asking. Do not run live providers, GUI compositor or full suite. Run
relevant headless tests using existing py313 and already-authorized
nix develop. If prerequisite changes grow too broad or unsafe stop
and report exact tradeoff. Update provenance for your changes
appropriately (same logical prerequisite patch may extend newest entry
without erasing original generation). Return code paths, exact test
evidence, risks and next live chart test requirements.
## Continuation Summary
Added explicit session-wide EMS mode plumbing across `_async_main`,
`GodWidget.load_symbols`, `display_symbol_data`, `open_order_mode`,
and `open_ems`. The existing string convention and live defaults are
preserved. Invalid startup modes fail before widget access. Added the
provider-search Trio startup handshake before application readiness.
New direct unit coverage is in `tests/ui/test_chart_startup.py`.
Combined with existing Qtractor and UI-isolation tests, 9 tests passed
in 2.67s; the separate display/gap-overlay run passed 9 in 2.00s.
Warnings were limited to existing Tractor deprecations. Full forwarding
and provider registration ordering remain live-integration coverage
requirements, not claims of these narrow tests.
This continuation changed four UI production files, added the startup
test file, and appended to this provenance pair. Original generation,
`_exec.py`, `test_qtractor.py`, `.gitignore`, `.ai`, and ai.skillz were
left untouched. No staging or Git history/worktree operations occurred.
## Continuation Human Contributions
The human selected paper execution and search readiness as the next
safety prerequisite, required shipped live defaults, prohibited
interlayer mocking without approval, limited verification to headless
offline tests, and authorized appending this existing provenance pair.
These requirements directed the generated edits. No direct manual
changes during this continuation were observed; acceptance remains
with the human.
## Live Qualification Prompt: 2026-09-08
The human explicitly authorizes public Kraken market data for
xbtusd.spot.kraken, headless integration, isolated registry/config,
and explicit paper execution with no credentials or real orders.
Implement the minimal production live chart test in this same owned
worktree, preserving the uncommitted prerequisites and unrelated
.gitignore/.ai state. No takeover, staging/commit, or ai.skillz edits.
Use the authoritative live-backend roadmap, required concurrency,
clearing, Python style and test-harness skills, and existing py313/Nix
wrapper. Add explicit --live-provider=kraken opt-in; preserve original
selected-run errors. Use production GodWidget and _async_main via
start_qtractor, owned empty broker config, unique reg_addr, inherited
piker_test_dir and isolated settings. Record paper/no-brokerd and
exact owned resources where possible. Always cancel in finally and
pump Qt with a bound before widget destruction. No new interlayer
mocks, symbol switching, blanket retries, broad cleanup, or expanded
test excludes. Stop if production blockers require broader repairs.
Append accurate gpt-6-astra provenance and human network authorization
to the newest pair, preserving earlier generation.
## Live Qualification Summary
Active model remains gpt-6-astra (`openai/gpt-6-astra`). Added the
explicit live-provider option and production Kraken smoke test.
Corrected one demonstrated Kraken public-client constructor keyword
from `conf` to `config`. No other production code was changed during
this continuation. Exact new patch scope is:
- `tests/conftest.py`: six-line opt-in option.
- `tests/ui/test_live_chart.py`: new production integration test.
- `piker/brokers/kraken/api.py`: one keyword correction.
- This existing Prompt-IO pair: append-only continuation records.
Final offline targeted checks: 9 passed, 1 skipped in 2.65s. The live
run failed before the keyword fix (1 failed, 1 teardown error in
2.66s), then progressed to public history and paper EMS but failed
again (1 failed, 1 teardown error in 12.44s). Readiness preceded
rt_linked.mode assignment; Qt reported abstract paint errors and
widget teardown survivors. No successful live qualification is claimed.
Commands, original signatures, proposed minimal readiness repair,
and evidence limitations are retained in the raw companion.
Guest completion preceded widget destruction, but Tractor escalated
its own child teardown to SIGKILL and a paperboi.kraken orphan remained:
PID 752893, UID e8317c39-ebbb-4526-85f0-3440a2e6e39c, PPID 1.
No agent-issued signal or SHM cleanup was performed. Further live
runs and production repairs were stopped. The final test improvements
to failure-path SHM/endpoint accounting were checked offline only.
The human directed all safety boundaries, the public network opt-in,
the minimal scope and stop condition, and continuation of this pair.
These are human-directed agent edits; acceptance remains with the
human. Earlier provenance, prerequisite work, task-state markers,
.gitignore, .ai, and ai.skillz are unchanged. No staging or commits.

View File

@ -0,0 +1,226 @@
---
model: openai/gpt-6-astra
service: opencode
timestamp: 2026-09-08T21:50:45Z
git_ref: 1a2d4893
diff_cmd: git diff HEAD
---
## Generated Changes
> `git diff HEAD -- piker/ui/_exec.py`
`start_qtractor()` copies global runtime defaults first and explicit
caller kwargs last. `run_qtractor()` uses PyQt6's `exec()` API.
Existing comments and exception logging are preserved.
> `git diff HEAD -- tests/ui/test_qtractor.py`
The existing repeated-session regression now seeds stale global
registry and runtime overrides before each startup. Its real guest
asserts the requested address is bound and the caller's runtime
overrides are active before publishing readiness.
A parametrized child-process regression exercises the real blocking
adapter with normal guest completion and a sentinel exception. It
checks exception identity, window cleanup, and actor teardown. Each
child uses offscreen Qt and test-owned config and registry addresses.
No public provider, paper mode, or chart readiness work is included.
## Verification
The existing worktree-local interpreter resolves Piker from this
worktree: Python 3.13.14, pytest-qt 4.5.0. The authorized Nix wrapper
ran its normal uv check; no environment provisioning was requested.
```xsh
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_qtractor.py
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_harness.py tests/ui/test_qtractor.py
```
The focused run passed 3 tests in 2.44s. The combined targeted run
passed 5 tests in 2.36s. Each reported two existing Tractor
`Registrar.uid` deprecation warnings. `git diff --check` passed.
Live Kraken charts, the full suite, paper mode, and readiness changes
were not run or implemented. The deployed harness still contains a
stale SIGINT warning for `MainWindow`; current source uses structured
shutdown. The reference was left untouched.
## Continuation: 2026-09-08T21:56:56Z
Model: openai/gpt-6-astra. The original generation above is preserved.
> `git diff HEAD -- piker/ui/_app.py`
`_async_main()` accepts `exec_mode: str = 'live'`, rejects values other
than 'live' and 'paper' before touching the widget, and stores the
selection on `GodWidget`. `load_provider_search()` publishes Trio task
startup after entering provider search registration and symcache.
The application awaits `nursery.start()` before publishing readiness.
> `git diff HEAD -- piker/ui/_widget.py`
> `git diff HEAD -- piker/ui/_display.py`
> `git diff HEAD -- piker/ui/order_mode.py`
`GodWidget.load_symbols()` forwards its session execution mode to
`display_symbol_data()`, which passes it to `open_order_mode()` and
then `open_ems(mode=exec_mode)`. Defaults remain live, matching the
existing CLI call. Keeping selection on the widget also covers later
search-driven symbol loads. No EMS, broker, or CLI implementation was
changed. Existing EMS code checks paper before acquiring brokerd;
paper relay cache keys are already distinct from live keys.
> `git diff HEAD -- tests/ui/test_chart_startup.py`
Added narrow direct tests for invalid-mode rejection before widget
access, existing CLI argument binding, explicit paper binding, and
entrypoint defaults. There are no interlayer mocks. These assertions
do not prove full chart forwarding or provider readiness at runtime.
### Verification
The authorized Nix wrapper and existing py313 interpreter resolve
Piker inside this worktree, with Python 3.13.14 and pytest-qt 4.5.0.
VIRTUAL_ENV is the worktree's py313 directory. Nix ran its normal uv
check; no separate environment provisioning was performed.
```xsh
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_chart_startup.py tests/ui/test_harness.py tests/ui/test_qtractor.py
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/test_gap_overlays.py
git diff --check
```
First run: 9 passed in 2.67s, two existing `Registrar.uid` deprecation
warnings. Second run: 9 passed in 2.00s, one upstream `trio.Event`
boolean-use deprecation warning. Diff whitespace check passed.
No live provider, compositor, or full-suite execution was performed.
Full chart readiness and zero-brokerd behavior need a separately
authorized live-feed test using explicit paper mode, isolated registry
and config, real search registration assertions, symbol switching,
and structured close/restart checks. Paper mode still accesses market
data and may write paper accounting state. It is not a process-wide
ban on other clients' live execution.
## Live Qualification Continuation: 2026-09-08
Active model: gpt-6-astra (`openai/gpt-6-astra`). Earlier content
above is historical and is preserved. The human now explicitly
authorizes public Kraken market data, headless Qt, and paper EMS in
this same worktree. No credentials, real orders, ownership takeover,
staging, commits, ai.skillz edits, or unrelated state changes are
authorized. The human requires an opt-in production chart test,
isolated registry/config, bounded guest teardown before widget
destruction, original errors, and stopping rather than broad repairs.
> `git diff HEAD -- tests/conftest.py`
> `git diff --no-index -- /dev/null tests/ui/test_live_chart.py`
> `git diff HEAD -- piker/brokers/kraken/api.py`
Added `--live-provider=kraken` and a production GodWidget/_async_main
journey through start_qtractor. The test supplies explicit paper mode,
the Tractor fixture's unique registry, owned root config paths, an
empty brokers.toml, and inherited piker_test_dir. Existing process XDG
isolation protects Qt settings and is inherited by children. There
are no interlayer mocks, submitted orders, or symbol interactions.
Qt polling races application readiness against guest completion.
Finally requests shutdown and pumps Qt with a 60-second bound before
normal pytest-qt widget destruction. Guest errors are unwrapped;
multiple body/teardown failures are grouped. Exact registry identities
and chart SHM names are recorded as pytest properties. Detached known
startup widgets are registered with QtBot after guest completion.
The test checks paper actors/no observed brokerd, title, markets,
nonempty history/realtime arrays, and a subsequent close-price change.
The first selected run reproduced a public-client constructor typo:
`get_client()` passed `conf={}` to `Client(config, httpx_client, ...)`.
Changed only that keyword to `config={}`. The next selected run got
past this defect and consumed public Kraken history with paper EMS.
### Exact Commands And Results
The existing authorized Nix wrapper resolves Python 3.13.14, Piker in
this worktree, pytest-qt 4.5.0, and VIRTUAL_ENV pointing at py313.
Its normal uv check ran; no separate provisioning was requested.
```xsh
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless -q -x --tb=short --no-header tests/ui/test_live_chart.py tests/ui/test_chart_startup.py tests/ui/test_qtractor.py tests/ui/test_harness.py
nix develop --command timeout -k 5 300 py313/bin/python -m pytest --headless --live-provider=kraken -q -x --tb=short --no-header tests/ui/test_live_chart.py
git diff --check
```
Offline command: initially 9 passed, 1 skipped in 2.66s; final rerun
9 passed, 1 skipped in 2.65s, with two Tractor deprecation warnings.
Diff whitespace check passed.
Live command was run twice with a targeted fix between runs, not as
a blanket provider retry. First: 1 failed, 1 teardown error, 4 warnings
in 2.66s. Original guest error was retained:
```text
piker/brokers/kraken/api.py:764 in get_client
Client(conf={}, httpx_client=trio_client)
TypeError: Client.__init__() got an unexpected keyword argument
'conf'. Did you mean 'config'?
```
Second: 1 failed, 1 teardown error, 13 warnings in 12.44s.
Readiness and observed paper actor/config checks passed, then:
```text
assert set(godw.rt_linked.mode.trackers) == {'paper'}
AttributeError: 'LinkedSplits' object has no attribute 'mode'.
Did you mean: 'move'?
```
`open_order_mode()` sets its started event at order_mode.py:1024,
before `_display.py:1693` assigns rt_linked.mode after entering chart
input handlers. `_async_main()` waits for the earlier event before
publishing session readiness. Minimal proposed follow-up: publish a
display-complete readiness event after these scopes and assignment,
rather than weakening this assertion or adding a timing sleep.
Qt also reported twice, without a Python item-identifying traceback:
```text
NotImplementedError: QGraphicsObject.paint() is abstract and must
be overridden
```
The UI isolation fixture reported remaining QFrame, QLabel, QMenu,
and ViewBoxMenu objects. Rendering and widget-ownership fixes were
not attempted. Identify the offending graphics item and give detached
chart resources explicit ownership in a separate bounded follow-up.
Guest completion was observed before widgets were destroyed, but it
did NOT prove clean actor teardown. Tractor logs show its own forced
SIGKILL escalation for datad, samplerd, emsd and both FSP actors.
A non-mutating `ps` check found the exact paper child still alive:
```text
PID 752893, PPID 1, paperboi.kraken
UID e8317c39-ebbb-4526-85f0-3440a2e6e39c
parent_addr ('127.0.0.1', 34915)
```
Checked known PIDs 738066, 738721, 749265, 749778, 750704, 751382,
751383, 752409 and 752893; only 752893 remained. No agent-issued
signals, manual SHM unlinking, broad cleanup, or subsequent live runs
were performed. The runtime's descendant teardown cause is not yet
established; it must be diagnosed before claiming clean shutdown.
After that failure, the test was tightened to collect chart SHM during
startup polling and check exact SHM/endpoints even after a body error.
The final version was checked offline only, since the paper survivor
remains. The reached run did not execute market/title/array/live-price
assertions or the original success-only SHM/endpoint checks. Those
are implemented expectations, not passing qualification evidence.
Further gaps: polling can miss transient actors; chart tokens omit
unpublished and non-chart child SHM; closed endpoints do not prove
PID exit; hard guest-timeout safety before widget destruction is not
qualified. No full suite, compositor, symbol switching, order journey,
or test-exclude changes were made. Existing prerequisite changes and
unrelated .gitignore/.ai state were preserved.

View File

@ -762,7 +762,7 @@ async def get_client() -> Client:
)
else:
client = Client(
conf={},
config={},
httpx_client=trio_client,
)

View File

@ -38,17 +38,26 @@ 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__)
async def load_provider_search(
brokermod: str,
brokermod: ModuleType,
loglevel: str,
) -> None:
*,
task_status: trio.TaskStatus[None] = trio.TASK_STATUS_IGNORED,
) -> None:
'''
Publish startup after provider search and symcache are installed.
'''
name = brokermod.name
log.info(f'loading datad for {name}..')
@ -70,6 +79,8 @@ async def load_provider_search(
f'BACKEND DOES NOT (yet) support symcaching: `{brokermod.name}`'
)
task_status.started()
# keep search engine stream up until cancelled
await trio.sleep_forever()
@ -82,14 +93,23 @@ async def _async_main(
syms: list[str],
brokers: dict[str, ModuleType],
loglevel: str,
exec_mode: str = 'live',
) -> None:
"""
'''
Main Qt-trio routine invoked by the Qt loop with the widgets ``dict``.
Provision the "main" widget with initial symbol data and root nursery.
"""
`exec_mode` selects 'live' or 'paper' EMS for this chart session,
including subsequent symbol loads through `GodWidget`.
'''
# Reject typos before chart tasks can request a live EMS dialog.
if exec_mode not in ('live', 'paper'):
raise ValueError(f'Invalid EMS execution mode: {exec_mode!r}')
main_widget.exec_mode = exec_mode
# enable chart's console logging
if loglevel:
get_console_log(
@ -165,7 +185,7 @@ async def _async_main(
# load other providers into search **after**
# the chart's select cache
for brokername, mod in needed_brokermods.items():
tn.start_soon(
await tn.start(
load_provider_search,
mod,
loglevel,
@ -194,6 +214,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()

View File

@ -1298,6 +1298,7 @@ async def display_symbol_data(
fqmes: list[str],
loglevel: str,
order_mode_started: trio.Event,
exec_mode: str = 'live',
) -> None:
'''
@ -1669,7 +1670,8 @@ async def display_symbol_data(
godwidget,
order_ctl_fqme,
order_mode_started,
loglevel=loglevel
loglevel=loglevel,
exec_mode=exec_mode,
) as mode,
# TODO: maybe have these startup sooner before

View File

@ -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,18 +302,65 @@ def run_qtractor(
instance: GodWidget = main_widget_type()
instance.window = window
# override tractor's defaults
tractor_kwargs.update(
get_runtime_vars()
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 = {
**get_runtime_vars(),
**(tractor_kwargs or {}),
}
# define tractor entrypoint
async def main():
async def main() -> None:
'''
Run the application under its owned Trio cancel scope.
async with maybe_open_pikerd(
**tractor_kwargs,
'''
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,
):
await func(*((instance,) + args))
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(
@ -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()

View File

@ -80,6 +80,8 @@ class GodWidget(QWidget):
'''
search: SearchWidget
mode_name: str = 'god'
# `_async_main()` selects EMS execution before any symbol loads.
exec_mode: str = 'live'
def __init__(
@ -207,6 +209,7 @@ class GodWidget(QWidget):
fqmes,
loglevel,
order_mode_started,
self.exec_mode,
)
# self.vbox.addWidget(hist_charts)

View File

@ -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:

View File

@ -785,7 +785,8 @@ async def open_order_mode(
godw: GodWidget,
fqme: str,
started: trio.Event,
loglevel: str = 'info'
loglevel: str = 'info',
exec_mode: str = 'live',
) -> None:
'''
@ -816,6 +817,7 @@ async def open_order_mode(
async with (
open_ems(
fqme,
mode=exec_mode,
loglevel=loglevel,
) as (
client,

View File

@ -123,6 +123,12 @@ def qapp_args(
def pytest_addoption(parser):
parser.addoption(
'--live-provider',
choices=('kraken',),
default=None,
help='Opt into public-provider chart qualification',
)
parser.addoption(
'--headless',
action='store_true',

View File

@ -0,0 +1,76 @@
from inspect import (
BoundArguments,
signature,
)
import pytest
import trio
from piker.ui._app import _async_main
from piker.ui._display import display_symbol_data
from piker.ui._widget import GodWidget
from piker.ui.order_mode import open_order_mode
@pytest.mark.parametrize('exec_mode', ['', 'Paper', 'dark'])
def test_chart_rejects_invalid_ems_mode(exec_mode: str) -> None:
'''
Reject misspelled execution modes before touching chart startup.
EMS treats non-paper mode as live when a backend supports trades.
A chart caller must not accidentally request that path with a
typo or the order-trigger mode 'dark'. Invoke the actual startup
coroutine with no widget or backend: ValueError, rather than a
widget access or runtime error, proves rejection precedes chart
initialization and any tasks that could acquire brokerd.
This is a direct input-boundary test, not an EMS integration test.
'''
with pytest.raises(ValueError, match='Invalid EMS execution mode'):
trio.run(
_async_main,
None,
[],
{},
'warning',
exec_mode,
)
def test_chart_ems_mode_call_contract() -> None:
'''
Preserve the shipped CLI argument shape while enabling paper EMS.
`_main()` passes only widget, symbols, brokers and loglevel to
`_async_main()`. Bind that concrete call shape and apply defaults
to verify it remains live; bind an explicit paper argument to
verify integration callers need no CLI or runtime adapter changes.
Check the widget and lower entrypoint defaults as well, since
omitted arguments must not silently change shipped execution.
These are direct API-contract assertions. They do not claim to
exercise forwarding through a running chart or broker selection.
'''
cli_call: BoundArguments = signature(_async_main).bind(
None,
['xbtusd.kraken'],
{},
'warning',
)
cli_call.apply_defaults()
assert cli_call.arguments['exec_mode'] == 'live'
paper_call: BoundArguments = signature(_async_main).bind(
*cli_call.args[:-1],
exec_mode='paper',
)
assert paper_call.arguments['exec_mode'] == 'paper'
assert GodWidget.exec_mode == 'live'
assert signature(display_symbol_data).parameters[
'exec_mode'
].default == 'live'
assert signature(open_order_mode).parameters[
'exec_mode'
].default == 'live'

View File

@ -0,0 +1,213 @@
from collections.abc import Callable
from pathlib import Path
import socket
from time import monotonic
from types import SimpleNamespace
import numpy as np
from PyQt6.QtWidgets import QApplication
import pytest
from pytestqt.qtbot import QtBot
import tractor
from tractor.ipc import _shm
from piker import config
from piker.brokers import get_brokermod
from piker.ui._app import _async_main
from piker.ui._exec import start_qtractor
from piker.ui._widget import GodWidget
def test_kraken_chart_boots_and_closes(
request: pytest.FixtureRequest,
isolated_ui_state: SimpleNamespace,
qapp: QApplication,
qtbot: QtBot,
reg_addr: tuple[str, int],
monkeypatch: pytest.MonkeyPatch,
record_property: Callable[[str, object], None],
) -> None:
'''
Qualify production chart boot against public Kraken, not mocks.
A blocking Qt host and hidden Trio errors previously prevented
testing the composed chart. Explicit selection starts GodWidget
and `_async_main` with paper EMS, an empty owned broker config,
and the test's unique registry. No orders or input are submitted.
Readiness races guest completion so a provider/startup exception
is unwrapped rather than replaced by a readiness timeout.
Nonempty historical/realtime visualizations and a later price
change prove actual feed consumption. Registry snapshots check
paperboi and absence of brokerd; exact chart SHM names and actor
endpoints are retained for non-mutating teardown checks. These
snapshots cannot rule out short-lived actors between polls or
account for SHM never published to a chart before early failure.
Every exit requests cancellation and pumps Qt to guest completion
before pytest-qt may destroy widgets. Cleanup and guest failures
are retained together rather than hiding either original error.
'''
if request.config.getoption('--live-provider') != 'kraken':
pytest.skip('requires --live-provider=kraken')
fqme: str = 'xbtusd.spot.kraken'
confdir: Path = isolated_ui_state.test_config_dir
# A missing brokers.toml loads templates with example accounts.
(confdir / 'brokers.toml').write_text('', encoding='utf-8')
for name in ('_config_dir', '_click_config_dir'):
monkeypatch.setattr(config, name, confdir)
monkeypatch.setattr(
config,
'_watchlists_data_path',
confdir / 'watchlists.json',
)
assert config.load('brokers')[0] == {}
owned_actors: dict[tuple[str, str], list] = {}
shm_names: set[str] = set()
errors: list[BaseException] = []
started: float = monotonic()
session = start_qtractor(
func=_async_main,
args=(
[fqme],
{'kraken': get_brokermod('kraken')},
'warning',
'paper',
),
main_widget_type=GodWidget,
tractor_kwargs={
'registry_addrs': [reg_addr],
'tractor_runtime_overrides': {
'piker_test_dir': str(confdir),
},
'loglevel': 'warning',
},
app=qapp,
)
def observe_runtime() -> None:
'''
Retain identities from this guest's local Registrar only.
'''
actor = tractor.current_actor(err_on_no_runtime=False)
if actor is not None:
assert actor.is_registrar
assert reg_addr in actor.accept_addrs
owned_actors.update(actor._registry)
assert not any(
uid[0].startswith('brokerd.')
for uid in owned_actors
)
for attr in ('hist_linked', 'rt_linked'):
linked = getattr(session.main_widget, attr, None)
chart = getattr(linked, 'chart', None)
viz = getattr(chart, 'main_viz', None)
if viz is not None:
token: dict = viz.shm.token
shm_names.update(
token[key] for key in (
'shm_name',
'shm_first_index_name',
'shm_last_index_name',
)
)
def boot_finished() -> bool:
observe_runtime()
return session.is_ready or session.outcome is not None
try:
qtbot.addWidget(session.window)
qtbot.waitUntil(boot_finished, timeout=120_000)
record_property('boot_seconds', monotonic() - started)
if session.outcome is None:
godw = session.main_widget
assert session.is_ready
assert godw.exec_mode == 'paper'
assert config.get_conf_dir() == confdir
assert tractor.runtime._state._runtime_vars[
'piker_vars'
]['piker_test_dir'] == str(confdir)
names: set[str] = {uid[0] for uid in owned_actors}
assert {
'pikerd', 'datad.kraken', 'samplerd',
'emsd', 'paperboi.kraken',
} <= names
assert set(godw.rt_linked.mode.trackers) == {'paper'}
assert not godw.rt_linked.mode.client._sent_orders
assert session.window.windowTitle().startswith(fqme)
for linked in (godw.hist_linked, godw.rt_linked):
assert linked.mkt.fqme == fqme
viz = linked.chart.main_viz
data = viz.shm.array
assert len(data) > 1
assert np.isfinite(data['close']).all()
assert (data['close'] > 0).any()
rt_shm = godw.rt_linked.chart.main_viz.shm
before = rt_shm.array[-1].copy()
def live_update_or_exit() -> bool:
observe_runtime()
if session.outcome is not None:
return True
# A sampler clock tick alone must not satisfy this.
return bool(
rt_shm.array[-1]['close'] != before['close']
)
qtbot.waitUntil(live_update_or_exit, timeout=60_000)
except BaseException as exc:
errors.append(exc)
finally:
session.request_shutdown()
try:
qtbot.waitUntil(
lambda: session.outcome is not None,
timeout=60_000,
)
session.outcome.unwrap()
except BaseException as exc:
errors.append(exc)
if session.outcome is not None:
# Early failure can leave these outside Qt parent trees.
for attr in ('search', 'hist_linked', 'rt_linked'):
widget = getattr(session.main_widget, attr, None)
if widget is not None and widget.parent() is None:
qtbot.addWidget(widget)
record_property('owned_actors', repr(owned_actors))
record_property('chart_shm_names', sorted(shm_names))
# Check exact survivors even when boot assertions already failed.
if tractor.current_actor(err_on_no_runtime=False) is not None:
errors.append(AssertionError('Guest runtime survived'))
for name in shm_names:
try:
segment = _shm.SharedMemory(name=name, create=False)
except FileNotFoundError:
continue
segment.close()
errors.append(AssertionError(
f'Chart SHM survived guest completion: {name}'
))
endpoints: set[tuple] = {reg_addr}
for addrs in owned_actors.values():
endpoints.update(map(tuple, addrs))
for addr in endpoints:
with socket.socket() as sock:
sock.settimeout(1)
if sock.connect_ex(addr) == 0:
errors.append(AssertionError(
f'Owned actor endpoint survived: {addr}'
))
if errors:
if len(errors) == 1:
raise errors[0]
raise BaseExceptionGroup('Chart and shutdown failed', errors)
assert session.is_ready, 'guest returned before readiness'

View File

@ -0,0 +1,268 @@
import os
from pathlib import Path
import subprocess
import sys
from textwrap import dedent
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 pytest
import tractor
import trio
from piker.service import get_runtime_vars
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],
reg_addr: tuple[str, int],
runtime_overrides: dict[str, Any],
) -> 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'
)
actor: tractor.Actor = tractor.current_actor()
assert reg_addr in actor.accept_addrs
assert tractor.runtime._state._runtime_vars[
'piker_vars'
] == runtime_overrides
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],
monkeypatch: pytest.MonkeyPatch,
) -> 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.
Before each start, seed `get_runtime_vars()` with a stale
loopback registry and conflicting runtime overrides. Previously
these globals overwrote explicit caller kwargs. Assertions inside
the guest verify the actual bound address and runtime state before
readiness, rather than merely checking forwarded arguments.
'''
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):
monkeypatch.setitem(
get_runtime_vars(),
'registry_addrs',
[('127.0.0.1', 0)],
)
monkeypatch.setitem(
get_runtime_vars(),
'tractor_runtime_overrides',
{'stale': True},
)
session: QtractorSession = start_qtractor(
func=run_until_window_close,
args=(
exited,
reg_addr,
tractor_kwargs['tractor_runtime_overrides'],
),
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
@pytest.mark.parametrize('fail', [False, True])
def test_run_qtractor_blocking_adapter(
reg_addr: tuple[str, int],
tmp_path: Path,
fail: bool,
) -> None:
'''
The blocking CLI adapter must drive PyQt6 and unwrap its guest.
`run_qtractor()` called the removed `QApplication.exec_()` API,
failing before Trio could start. Run the real adapter in a fresh
process with an offscreen application and test-owned registry and
config. The guest either returns normally or raises a sentinel
error after registering its window for inspection.
Guest completion must quit `QApplication.exec()` without a timer
or external shutdown. A bounded subprocess wait detects hangs;
exit status, propagated error identity, and the cleared actor and
window slots prove both outcome paths finish structured teardown.
Process isolation keeps the CLI's Qt ownership out of pytest-qt.
'''
script: str = dedent(f'''
from PyQt6.QtGui import QResizeEvent
from PyQt6.QtWidgets import QWidget
import tractor
from piker.ui import _window
from piker.ui._exec import run_qtractor
class Widget(QWidget):
def on_win_resize(self, event: QResizeEvent) -> None:
pass
windows: list[QWidget] = []
error = RuntimeError('qtractor guest sentinel')
async def main(widget: Widget) -> None:
windows.append(widget.window)
assert {reg_addr!r} in (
tractor.current_actor().accept_addrs
)
if {fail!r}:
raise error
try:
run_qtractor(
func=main,
args=(),
main_widget_type=Widget,
tractor_kwargs={{
'registry_addrs': [{reg_addr!r}],
'tractor_runtime_overrides': {{
'piker_test_dir': {str(tmp_path)!r},
}},
}},
)
except RuntimeError as exc:
assert {fail!r}
assert exc is error
else:
assert not {fail!r}
assert len(windows) == 1
assert windows[0].qtractor_session is None
assert _window._qt_win is None
assert tractor.current_actor(
err_on_no_runtime=False,
) is None
''')
result: subprocess.CompletedProcess[str] = subprocess.run(
[sys.executable, '-c', script],
env={
**os.environ,
'QT_QPA_PLATFORM': 'offscreen',
'XDG_CONFIG_HOME': str(tmp_path / 'config'),
'XDG_CONFIG_DIRS': str(tmp_path / 'system-config'),
},
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stdout + result.stderr