diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15fea8e..c3b280e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable changes to TextUI are documented here.
### Fixed
+- Runtime table sorting preserves large-integer precision without float conversion, treats numeric NaN deterministically as text, and keeps table state unchanged when a header comparison fails. Refresh continues to retain the active sort and declared row identity.
+- Project timers release finished async and threaded workers, including failed or cancelled work. Repeated close clears timer and worker ownership without suppressing native worker errors or changing overlap prevention.
+- Actions and commands now share lifecycle ownership: synchronous completion releases loading, shared targets stay loading until all work finishes, and older failures cannot overwrite newer target state. Shutdown cancels untargeted work and queued command wrappers as well as targeted work, and closed documents reject new invocations.
- Focus cues now blend over native and author backgrounds instead of replacing them, so variant buttons retain their colors. Compact inputs, selects, and text areas also preserve their backgrounds; tint strengths remain 15% by default and 25% for compact editable controls.
- Clicking a data-table column heading now sorts once per click. The table's click and mouse-move handlers also called `super()`, which Textual had already run, so every heading click was delivered twice and the ascending/descending toggle always ended on descending. A column-resize press no longer sorts either, and no longer swallows the next heading click when the drag is released.
- Controller resize hooks now receive terminal dimensions even when the active screen has padding or a border.
diff --git a/README.md b/README.md
index bac3f11..9deb724 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,8 @@ TextUI 0.6 turns strict XML documents into native [Textual](https://textual.text
This is a breaking pre-1.0 reboot. See the [migration guide](docs/migration.md) for changes from 0.1, the [changelog](CHANGELOG.md) for release history, and the [implemented design](docs/superpowers/specs/2026-09-17-textui-core-design.md) for the complete contract.
+The [roadmap](docs/roadmap.md) records current priorities, completion gates, and the reliability implementation plan. Historical library comparisons remain in the [extension triage](docs/2026-09-19-extension-triage.md).
+
## Install and run
Python `>=3.11,<4` is required; the release matrix covers 3.11, 3.12, and 3.14. Core dependencies are Textual `>=8.2.8,<9` and lxml `>=6.1.3,<7`. Core installation does not require Pillow or textual-imageview; image components are a future extension.
@@ -140,6 +142,8 @@ Host(DocumentLoader().from_string("")).run()
`TextUI` supplies these built-in handlers for convenience. Add decorators for any other component messages used by your document. `get_by_id` returns only widgets with declared document IDs and requires them to be mounted. Use native `app.query()` / `app.query_one()` for general selectors. A `Document` can be reused in independent Apps; each binding constructs fresh widgets. There is one binding per App and a single composition attempt per binding. Recomposition, remounting, document replacement, and transparent attachment to a running App are unsupported.
+Normal hosts must call `self.document.close()` when shutdown begins and on unmount to cancel owned actions and commands; the convenience Apps do this automatically. Closing is idempotent and ignores later queued document events. See the [runtime lifecycle guide](docs/project-runtime.md) for shared loading targets, supersession, and cooperative cancellation.
+
## Add components and events
`ComponentRegistry()` starts empty. To extend the built-ins, use `default_component_registry` from `textui.widgets.builtin_widgets`, then register additional immutable `ComponentSpec` definitions. Construct `DocumentLoader(registry)` after registration; the loader snapshots the registry.
diff --git a/docs/2026-09-19-extension-triage.md b/docs/2026-09-19-extension-triage.md
index 9169625..6417bd5 100644
--- a/docs/2026-09-19-extension-triage.md
+++ b/docs/2026-09-19-extension-triage.md
@@ -1,7 +1,9 @@
# TextUI Extension Triage and Delivery Order
Date: 2026-09-19
-Updated: 2026-09-20
+Updated: 2026-09-30
+
+Current delivery priorities and completion gates are maintained in the [TextUI roadmap](roadmap.md). This document preserves the earlier ecosystem comparisons; external compatibility and license evidence below dates from 2026-09-20 and must be rechecked before adoption.
## Direction
@@ -34,9 +36,11 @@ The historic implementation plans remain as engineering records. Their unchecked
## Remaining roadmap
+The current roadmap places runtime reliability and authoring tools before these extensions. Native `` has shipped; the table below retains the extension grouping, with its completed control removed from the remaining work.
+
| Order | Deliverable | Scope and rationale |
| --- | --- | --- |
-| 1 | Focused controls and dashboard widgets | Add native ``, ``, and `` adapters. These have clear markup contracts and fill common form/dashboard gaps without a reactive template language. |
+| 1 | Focused controls and dashboard widgets | Add a native `` adapter and evaluate ``. These have clear markup contracts and fill common form/dashboard gaps without a reactive template language. |
| 2 | Selection and state presentation | Add a `selection-list` adapter, autocomplete/combobox behavior, and reusable loading, empty, and error presentation patterns. |
| 3 | Command surfaces | Build menus or a command palette on the existing `@command` metadata when an application needs them. Dynamic enabled predicates and user-configurable shortcuts remain separate design work. |
| 4 | Reactive navigation | Define observation, refresh, routing, and history only after a project demonstrates the need beyond the existing content switcher, runtime lists, and tables. |
diff --git a/docs/controls.md b/docs/controls.md
index 7150a35..f3460f9 100644
--- a/docs/controls.md
+++ b/docs/controls.md
@@ -86,6 +86,8 @@ Set `striped="true"` for alternating row backgrounds; style native `datatable--o
Runtime rows sort by their original values, so numeric fields remain numeric; seeded and manually added literal cells sort by their displayed values. Call `set_rows(records)` only after mounting. Every record must be a mapping with a non-empty string in the declared `row-key` field and every declared column key. TextUI validates the full batch before changing rows, renders `None` as an empty literal cell, and retains extra fields without adding columns. `get_record(row_key)` returns the read-only source record for the current batch and raises `KeyError` when absent. A refresh retains the active sort and cursor when its row key remains; otherwise the native cursor returns to the first cell. The [data example](../examples/data/app.ui) shows this runtime pattern beside seeded table and tree updates.
+Ascending runtime sorts group booleans first, then `numbers.Real` values in numeric order, then other values as case-folded text. Real values are compared directly, preserving large-integer precision; infinities remain numeric. Numeric NaN sorts as text `"nan"`, and `None` sorts as `"None"` despite its empty displayed cell. `Decimal` and other non-Real objects retain textual ordering. Descending reverses category and value order; equal keys remain stable in both directions. Validation or comparison failure leaves existing rows, cursor, and active sort unchanged.
+
## Log selection
`` reports a nonempty mouse selection after release, even outside the log. Actions receive `context.event.log`, `.text`, and native `.selection` coordinates. Appending or redrawing content does not repeat the event. Copying is opt-in: a linked async action can call `await window.copy(context.event.text)`. See the [clipboard runtime guide](project-runtime.md#selection-and-clipboard) for native backend and terminal transport behavior.
diff --git a/docs/project-runtime.md b/docs/project-runtime.md
index af26c01..306edfb 100644
--- a/docs/project-runtime.md
+++ b/docs/project-runtime.md
@@ -88,7 +88,9 @@ async def refresh():
An `@action` function is exposed under its Python name and may take zero arguments or one `ActionContext`; undecorated functions are private to the script. `on-pressed="save"` refers to that exact name. Duplicate action names, including collisions with host-supplied actions, are errors.
-Async data actions can opt into target lifecycle state. Set `target` to a declared widget ID to add `-loading` while work runs and `-error` with a readable `textui_error` value when it fails. Set `supersede=True` to cancel an earlier invocation of the same action and target; shutdown also cancels active lifecycle work. `context.target` is the mounted target and `context.cancelled` reports cancellation.
+Actions and commands can opt into target lifecycle state, for synchronous or asynchronous work. Set `target` to a declared widget ID to add `-loading` while work runs and `-error` with a readable `textui_error` value when it fails. A shared target stays loading until every active invocation finishes; synchronous completion also releases its ownership. Starting new work clears previous errors, and only the newest invocation may publish a target error. An older failure still reaches the normal source-located error handler.
+
+Set `supersede=True` to cancel earlier invocations of the same operation and target, including when the replacement completes synchronously. Other operations sharing the target continue. `context.target` is the mounted target and `context.cancelled` reports cancellation, including for untargeted actions. Cancellation is cooperative: callbacks that suppress it and threads may still perform their own side effects.
```python
@action(target="results", supersede=True)
@@ -118,6 +120,8 @@ Render it with a self-labeling control:
Optional `on_setup`, `on_ready`, and `on_close` functions take no arguments and may be synchronous or asynchronous. Setup runs before component validation and binding; ready runs after mount; close runs once on shutdown and after a failed setup. `window.document` is available after binding, while ID lookup requires mounted widgets.
+Both convenience Apps close their document when exit begins, cancelling all owned asynchronous actions and commands, including untargeted work and queued shortcut wrappers. Repeated `document.close()` calls are harmless. A closed document ignores queued `dispatch()` messages, rejects direct `invoke_command()` calls with `DocumentStateError`, and makes `start_command()` a no-op. Normal Textual hosts must call their bound document's `close()` when shutdown begins and on unmount; it requests cancellation without waiting for user callbacks.
+
Optional `on_resize(width, height)` runs after Textual delivers a terminal resize to the active screen and refreshes its layout. Width and height are terminal cells; mounted widget sizes can be read inside the hook. It accepts a synchronous or asynchronous function, runs only while the project is ready, and stops when exit begins. TextUI drops stale and duplicate sizes; Textual may also coalesce rapid terminal resizes, so paint from the dimensions supplied rather than counting raw resize events:
```python
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..7e3e236
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,77 @@
+# TextUI Roadmap
+
+Updated: 2026-09-30. Status: planned. Review baseline: `f8e472c` (TextUI 0.6.0).
+
+This is the current delivery order. The [extension triage](2026-09-19-extension-triage.md) retains the historical library comparisons and adoption rationale. Its external compatibility claims are dated evidence and must be checked again before adding a dependency.
+
+## Direction
+
+Keep the HTML-like authoring model: strict XML describes structure, native TCSS describes appearance, and linked Python supplies behavior. Preserve explicit registry extensions, normal Textual App integration, contextual errors, and the image-free core dependency boundary.
+
+Prioritize reliable application lifecycles and approachable authoring before expanding the widget catalog. Each phase has a completion gate; the numbers express dependencies, not calendar promises.
+
+## Current baseline
+
+Main includes reusable local components and slots; linked scripts, includes and styles; actions, commands and timers; context and resize hooks; navigation, splits and modals; runtime tables/lists; compact/border presets; gradients; native range controls; autofocus on reveal; selectable logs and asynchronous clipboard copying.
+
+The review ran 389 committed headless tests successfully, but separate behavioral probes exposed five defects. A green baseline does not waive the regression tests below.
+
+## Phase 0 — Reliability
+
+| Unit | Deliverable | Completion gate |
+| --- | --- | --- |
+| 0A | Shared action/command ownership and lifecycle cleanup | Untargeted tasks are cancelled on close; sync completion/failure cleans target state; loading remains while any action owns the target; supersession cannot publish stale target state. |
+| 0B | Timer worker cleanup | Completed async/threaded workers are released; repeated close stops timers and clears ownership; overlap skipping and native error reporting remain intact. |
+| 0C | Exact table sorting | Adjacent large integers and arbitrarily large integers sort correctly; mixed numeric/text values, missing values, NaN and infinity have documented deterministic behavior; refresh retains sort and row identity. |
+
+Implement 0A first because it defines shared ownership semantics. 0B and 0C are independent afterward and may use separate worktrees. Integrate them into one reviewed reliability PR with separate logical commits, avoiding competing edits to shared documentation.
+
+Detailed artifacts: [reliability design](superpowers/specs/2026-09-30-runtime-reliability-design.md) and [implementation plan](superpowers/plans/2026-09-30-runtime-reliability.md).
+
+## Phase 1 — Developer experience
+
+| Unit | Scope | Completion gate |
+| --- | --- | --- |
+| 1A | Dependable setup and showcase launch | Document one isolated environment path and a module-based launch; verify from a clean checkout and another working directory. A small launcher may wrap the existing Poetry workflow. |
+| 1B | Project validation and CLI diagnostics | Add a headless `textui check` workflow, concise expected-error output and stable exit codes; missing files, XML, TCSS and action errors retain their source location. Keep tracebacks available for unexpected failures/debugging. |
+| 1C | Registry-derived authoring reference | Generate tags, attributes, defaults, events and examples from registry metadata; provide machine-readable completion data. Compound structural rules remain explicit, and custom components remain supported. |
+| 1D | Documentation consolidation | Separate current reference from historical design records; reconcile migration/examples/testing guidance and shipped roadmap items; make install, launch and extension paths easy to find. |
+
+1A and 1D can proceed alongside Phase 0. Design 1B's execution boundary first: full validation may need trusted controller setup to register components. The command must explain which hooks execute and must not claim static or sandboxed validation. 1C depends on that boundary and on an explicit metadata format; a complete XML schema is a later step if it can express compound rules accurately.
+
+## Phase 2 — Compatibility and maintenance
+
+- Run focused behavioral probes against the lowest and latest allowed Textual versions, supplementing the locked full suite and existing clean-wheel smoke test. Cover styling, gradients, table rendering/resizing, selection, resize hooks, modals and shutdown.
+- Add focused macOS/Windows CI for platform-specific code. Keep mocked clipboard tests; exercise real native tools only in disposable CI environments. Terminal OSC 52 acceptance remains a separate manual check.
+- Expand clean-wheel checks to newer runtime facilities and verify that optional imaging packages remain absent.
+- Share built-in message forwarding between convenience Apps while preserving exact-type dispatch and explicit custom-event forwarding in normal hosts.
+- Improve public typing and editor support for the injected `window`; introduce type checks incrementally around supported public interfaces.
+
+Gate: reviewed compatibility coverage, clean installed-wheel checks, documented Textual internal touchpoints, and no regression in host extension behavior. Preserve the Poetry lock policy; do not hand-edit dependency resolutions.
+
+## Phase 3 — Focused application capabilities
+
+| Order | Capability | Boundary and acceptance |
+| --- | --- | --- |
+| 3A | `` | Small native adapter with validated numeric data and a controller update API; include a live showcase trend and empty-data coverage. |
+| 3B | `` | Explicit selected values, stable item identity, disabled choices, runtime replacement and one selection-changed event; keyboard and pointer tests. |
+| 3C | Command palette | Reuse existing command metadata and invocation ownership; searchable labels/descriptions, keyboard access and correct disabled-command behavior. |
+| 3D | Loading, empty and error presentation | Reusable component patterns built on proven target lifecycle semantics; include retry examples without automatic network/retry policy. |
+
+Each capability gets a small design and implementation plan before code. Define its runtime update and event semantics, not just its markup spelling. Keep the showcase and reference synchronized with each addition.
+
+## Phase 4 — Extensions and richer authoring
+
+- Optional plotting: axes, legends and multiple series in a separate adapter/package after sparkline establishes the basic dashboard contract.
+- Optional images: revisit the preferred renderer's compatibility and license before a separate image distribution; retain Python 3.11 and the image-free core.
+- Date picking and autocomplete/combobox: decide ISO value, provider, identity and refresh behavior before adopting a library.
+- Component-local styling and explicit state updates: design scope, cascade, ownership and instance isolation against a real application example.
+- Routing/history and dynamic component properties: pursue when a concrete application exceeds the current content-switcher and controller APIs.
+
+Embedded Python, expression evaluation, automatic data binding, hot reload/recomposition, terminal emulation and an untrusted-document mode remain separate architectural work, with no delivery commitment here.
+
+## Delivery rules
+
+Use an isolated worktree for implementation and preserve root-checkout edits. Write symptom-driven regressions before fixes. Update user-facing docs/examples and `CHANGELOG.md` when behavior changes. Run focused checks, the full committed suite, relevant visual checks, Pyflakes, lock validation and builds; use Python 3.11/3.12/3.14 for releases/dependency changes. Obtain independent review, inspect GitHub feedback and resolve verified findings before merging. Read the historical review threads again after merge if feedback arrives late.
+
+Move a unit to completed only when its gate is met. Record the merged PR and validation evidence here; unchecked historical plan lists do not override this current status.
diff --git a/docs/superpowers/plans/2026-09-30-runtime-reliability.md b/docs/superpowers/plans/2026-09-30-runtime-reliability.md
new file mode 100644
index 0000000..dcb53dc
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-30-runtime-reliability.md
@@ -0,0 +1,180 @@
+# Runtime Reliability Implementation Plan
+
+> **For agentic workers:** Use `superpowers:executing-plans` for inline execution and independent code review before integration. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix all five confirmed runtime reliability defects without changing markup or public decorator signatures.
+
+**Architecture:** BoundDocument shares a private invocation owner across actions and commands. Timer ownership consumes native Worker terminal-state messages. Tables compare numeric values without float coercion.
+
+**Tech Stack:** Python 3.11+, Textual 8, Poetry 2.4.3, pytest and pytest-asyncio.
+
+**Spec:** [Runtime reliability design](../specs/2026-09-30-runtime-reliability-design.md).
+
+## Global Constraints
+
+- Python `>=3.11,<4`; Textual `>=8.2.8,<9`; lxml `>=6.1.3,<7`.
+- Poetry 2.4.3; no added product dependencies or hand-edited lock resolutions.
+- Strict XML, linked trusted Python, native TCSS and explicit custom-message forwarding remain unchanged.
+- Core must not import or install Pillow or textual-imageview.
+- Tests use headless Textual/Pilot and strict function-scoped asyncio loops; screenshots remain opt-in.
+- Preserve exact registered message-type/source identity dispatch, immediate-error propagation, delayed-error reporting with source/cause, and native bubbling.
+- Cancellation is cooperative. This work cannot force-stop a thread or prevent side effects from application code that deliberately suppresses cancellation.
+
+## Review Focus
+
+1. Closing immediately after a shortcut is scheduled must cancel the wrapper before a callback starts. Task 1 tests this separately from cancelling an already-started action.
+2. An old superseded callback that catches cancellation and fails later must not overwrite newer target state. Task 1 verifies state and source-located error reporting separately.
+3. Two different actions sharing a target must retain loading after either completes first. Task 1 tests both completion orders, including a command sharing the target.
+4. A threaded worker may finish before ownership registration; it must still be released. Task 2 exercises fast async/threaded callbacks and late terminal messages.
+5. Integers outside float range, NaN and mixed types must not break sorting or mutate a rejected replacement. Task 3 tests values and retained table state.
+
+## Preparation and commands
+
+At execution time, create `fix/runtime-reliability` in an isolated worktree using the worktree skill. Carry these committed planning files into it and preserve root untracked files, including the legacy `tests/test_markup.py`. Install the locked environment and run the clean worktree suite; the review baseline was 389 passing tests.
+
+Every Poetry command below uses the full prefix:
+
+```sh
+uvx --python 3.12 --from poetry==2.4.3 poetry install --with test
+uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest -q
+```
+
+Execute Task 1 first. Tasks 2 and 3 share no new interfaces and can be developed independently afterward; integrate and verify one branch before opening the PR.
+
+## Task 1 — Unify action and command lifecycle ownership (R1–R3)
+
+**Files:** create `textui/invocations.py`; modify `textui/document.py`, `textui/project_app.py`, `textui/textui.py`; test `tests/test_actions.py`, `tests/test_project_app.py`; update `docs/project-runtime.md`, `README.md`, `CHANGELOG.md`.
+
+**Interfaces:** private `OwnedInvocation` holds `state: ActionInvocation`. `InvocationOwner.begin(name: str, target: Widget | None, *, supersede: bool) -> OwnedInvocation`, `track(invocation: OwnedInvocation, task: asyncio.Future[object]) -> None`, `finish(invocation: OwnedInvocation, error: Exception | None = None) -> None`, `close() -> None`, and read-only `closed: bool`. BoundDocument still wraps errors and owns shortcut wrappers. Existing public dispatch/command/close signatures stay unchanged.
+
+- [ ] **Step 1: Write failing behavioral tests.** Reuse the normal Host binding pattern in `tests/test_actions.py` and `project()` helper in `tests/test_project_app.py`. Use Events to hold async work, not arbitrary sleeps. Cover the named cases and assertions below for actions and commands where applicable:
+
+```python
+# test_sync_target_action_and_command_complete_state
+assert not status.has_class("-loading")
+# test_sync_target_failure_sets_error_and_preserves_source
+assert status.has_class("-error") and status.textui_error == "refresh failed"
+assert error.value.__cause__ is original_error
+# test_shared_target_keeps_loading_in_both_completion_orders
+assert status.has_class("-loading") # after one invocation finishes
+# test_shutdown_cancels_untargeted_action_and_command
+assert cancellation_seen.is_set() and post_close_effects == []
+# test_close_before_shortcut_wrapper_starts
+assert callback_calls == []
+# test_closed_document_rejects_new_work
+assert await bound.dispatch(message) is False
+# invoke_command raises DocumentStateError; start_command creates no task.
+# test_superseded_failure_does_not_overwrite_newer_target_state
+assert not status.has_class("-error") # newer generation succeeded
+```
+
+Also exercise repeated close, synchronous completion while another action owns the target, cancellation with no error indicator, native queued events during exit, and TextUI as well as ProjectApp cleanup. The superseded failure still reports a source-located ActionExecutionError through a capturing host error handler.
+
+- [ ] **Step 2: Run and record the expected failures.**
+
+```sh
+uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/test_actions.py tests/test_project_app.py -q
+```
+
+Expected: new tests fail for retained untargeted tasks, sync loading, shared-target loading and absent close guards; existing regressions remain green.
+
+- [ ] **Step 3: Implement ownership and route both paths through it.** Use per-target active invocation sets/generations and separate `(name, target)` supersession keys. Begin before callback evaluation; finish sync results/errors directly and awaitable results through task completion. Make finishing idempotent. Track every callback task and shortcut wrapper. Close before App teardown; ignore later completion bookkeeping safely. Preserve immediate versus deferred exception handling and disabled commands.
+- [ ] **Step 4: Run focused tests and review the new lifecycle cases.** Use the command in Step 2. Expected: all focused tests pass, and none reports a cancelled task as an application error. Update runtime/host shutdown guidance and the Fixed changelog entries in the same change.
+- [ ] **Step 5: Commit only this deliverable.** Suggested summary: `Unify action ownership and shutdown cleanup`.
+
+## Task 2 — Release finished timer workers (R4)
+
+**Files:** modify `textui/timers.py`, `textui/project_app.py`; test `tests/test_project_timers.py`; update `CHANGELOG.md`.
+
+**Interfaces:** retain `RuntimeTimers.schedule(...)` and returned native Timer handles. Add private `_own_worker(worker: Worker) -> None` and `handle_worker_state(event: Worker.StateChanged) -> None`; ProjectApp forwards native worker-state messages to its timer owner. Only Workers in the owned collection are handled.
+
+- [ ] **Step 1: Write failing async and threaded retention tests.** Schedule at least 20 completed ticks, stop the handle, then await native worker completion; assert the owned collection is empty. Use an Event/counter for completion. Also test fast completion, failed/cancelled workers, an unrelated host Worker, and late messages after repeated close:
+
+```python
+assert tick_count >= 20
+assert all(worker.is_finished for worker in observed_workers)
+assert app.window.timers.workers == set()
+app.window.timers.close()
+app.window.timers.close()
+assert app.window.timers.handles == []
+```
+
+Capture worker references only in the test; production ownership must release them. Retain existing tests for skipped overlapping ticks and source-located timer failures.
+
+- [ ] **Step 2: Verify failure.**
+
+```sh
+uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/test_project_timers.py -q
+```
+
+Expected: completed-worker retention and uncleared close collections fail.
+
+- [ ] **Step 3: Implement cleanup.** Add/check registration atomically on the UI side, including `worker.is_finished` after adding it. Remove owned workers on native SUCCESS/ERROR/CANCELLED messages without stopping normal error reporting. Stop handles and cancel workers using snapshots before clearing collections; tolerate late messages and preserve overlap skipping.
+- [ ] **Step 4: Run the command in Step 2.** Expected: all timer tests pass for async/threaded paths. Add a Fixed changelog entry.
+- [ ] **Step 5: Commit.** Suggested summary: `Release completed timer workers`.
+
+## Task 3 — Preserve exact numeric table ordering (R5)
+
+**Files:** modify `textui/widgets/data_widgets.py`; test `tests/test_data_widgets.py`; update `docs/controls.md`, `CHANGELOG.md`.
+
+**Interfaces:** retain `set_rows(rows) -> None`, row lookup and header-click sorting. Change private `_sort_value(value: object) -> tuple[int, Real | str]` to preserve Real values; NaN falls through to text ordering. Preserve existing bool/numeric/text categories and stable ties.
+
+- [ ] **Step 1: Write failing header-interaction and refresh tests.** Supply larger-before-smaller rows, click the numeric heading, verify ascending then descending, and refresh with different records while sorting remains active:
+
+```python
+# test_runtime_table_sort_preserves_large_integer_precision
+assert ordered_values == [2**53, 2**53 + 1]
+# test_runtime_table_sort_handles_integers_outside_float_range
+assert ordered_values == [10**400, 10**400 + 1]
+# test_numeric_sort_policy_covers_mixed_values
+# Ascending category policy for the supplied records:
+assert ordered_values[:8] == [False, True, float("-inf"), 1, 1.5,
+ 10**400, float("inf"), "alpha"]
+assert ordered_values[8] is nan and ordered_values[9] is None
+```
+
+For the mixed test compare NaN by identity or `math.isnan`, never equality; textual ordering is `alpha`, `nan`, `None`. Also pin Decimal textual ordering, stable equal values, retained selected row keys, and rejected replacements leaving rows/cursor/sort direction unchanged.
+
+- [ ] **Step 2: Verify failures.**
+
+```sh
+uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/test_data_widgets.py -q
+```
+
+Expected: adjacent large integers remain incorrectly ordered; huge integers overflow before the fix. NaN behavior is not deterministic under the old numeric key.
+
+- [ ] **Step 3: Implement exact keys and validate before mutation.** Avoid float conversion for Real values. Detect numeric NaN without converting large integers to float. Build sorted replacements before clearing rows or changing sort metadata. Keep header indicators, refresh sorting, cursor restoration and native seeded-string behavior intact.
+- [ ] **Step 4: Run the command in Step 2.** Expected: all table tests pass in both directions. Document the value categories and special-value policy, and add a Fixed changelog entry.
+- [ ] **Step 5: Commit.** Suggested summary: `Preserve numeric precision in table sorting`.
+
+## Task 4 — Integration, review and delivery
+
+**Files:** the changes above; update `docs/roadmap.md` with evidence only after merge.
+
+- [ ] Run the full committed worktree suite with the preparation command. Expected: no failures; the count will exceed the 389-test baseline.
+- [ ] Run relevant visual checks:
+
+```sh
+TEXTUI_VISUAL_TESTS=1 uvx --python 3.12 --from poetry==2.4.3 poetry run python -m pytest tests/visual -q
+```
+
+Expected: reviewed baselines pass; a lifecycle/sorting fix should not require unrelated baseline updates.
+
+- [ ] Run packaging and lint checks:
+
+```sh
+uvx --python 3.12 --from pyflakes pyflakes textui
+uvx --python 3.12 --from poetry==2.4.3 poetry check --lock
+uvx --python 3.12 --from poetry==2.4.3 poetry build
+git diff --check
+```
+
+Expected: no lint/diff errors, consistent metadata/lock and successful wheel/sdist build.
+
+- [ ] Obtain independent review of R1–R5 and the five Review Focus conditions. Fix important findings and rerun affected checks.
+- [ ] Open one reliability PR linking this spec/plan, the reproductions, behavior changes and validation evidence. Wait for Python 3.11/3.12/3.14, visual, lint and clean-wheel CI; inspect reviews/comments, fix verified feedback and resolve applicable threads.
+- [ ] Merge the verified PR, sync the root checkout without altering unrelated files, and record the merged PR/test results against Phase 0. Recheck late feedback before starting Phase 1.
+
+## Self-review
+
+Task 1 covers R1–R3 and close/supersession edge cases; Task 2 covers R4 and completion-registration races; Task 3 covers R5 and sort/refresh atomicity; Task 4 covers integration and review. Later roadmap phases require their own bounded designs and plans. This plan does not authorize changing dependencies or introducing new public runtime abstractions.
diff --git a/docs/superpowers/specs/2026-09-30-runtime-reliability-design.md b/docs/superpowers/specs/2026-09-30-runtime-reliability-design.md
new file mode 100644
index 0000000..4f5f786
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-30-runtime-reliability-design.md
@@ -0,0 +1,66 @@
+# Runtime Reliability Design
+
+Date: 2026-09-30. Status: proposed for implementation. Baseline: `f8e472c`.
+
+## Purpose and scope
+
+Implement Phase 0 of the [roadmap](../../roadmap.md): fix five defects confirmed by headless review probes. Consolidate only the duplicated lifecycle logic needed to fix them. Keep markup, action/command decorators and native host integration compatible.
+
+| Finding | Evidence at baseline | Owner |
+| --- | --- | --- |
+| R1: async work survives shutdown | An untargeted action remained pending after ProjectApp closed, then resumed with `window.phase == "closed"`. `document.py:424` cancels only lifecycle-target tasks. | 0A |
+| R2: synchronous target state is never completed | `@action(target="status") def refresh(): pass` returned with `-loading` still set. Synchronous exceptions also bypass target error cleanup. See `document.py:552`. | 0A |
+| R3: shared-target loading is cleared early | Two different actions targeting `status` ran concurrently; completing the first removed loading with the second still pending. See `document.py:579`. | 0A |
+| R4: completed timer workers are retained | Fifteen async ticks retained fifteen finished workers; threaded ticks use the same retained collection. See `timers.py:76,92`. | 0B |
+| R5: numeric sorting loses precision | Ascending rows containing `2**53 + 1`, then `2**53`, remained in that order because both keys became the same float. See `data_widgets.py:359`. | 0C |
+
+## Constraints
+
+- Python `>=3.11,<4`; Textual `>=8.2.8,<9`; lxml `>=6.1.3,<7`.
+- Poetry 2.4.3; no added product dependencies or hand-edited lock resolutions.
+- Strict XML, linked trusted Python, native TCSS and explicit custom-message forwarding remain unchanged.
+- Core must not import or install Pillow or textual-imageview.
+- Tests use headless Textual/Pilot and strict function-scoped asyncio loops; screenshots remain opt-in.
+- Preserve exact registered message-type/source identity dispatch, immediate-error propagation, delayed-error reporting with source/cause, and native bubbling.
+- Cancellation is cooperative. This work cannot force-stop a thread or prevent side effects from application code that deliberately suppresses cancellation.
+
+## 0A — Invocation ownership
+
+Extract a private `InvocationOwner` in `textui/invocations.py`. BoundDocument remains responsible for event matching, callback arguments, public errors and delivery to Textual's native error path. The owner handles invocation records, target state, supersession and asynchronous task ownership for both event actions and commands.
+
+Each private `OwnedInvocation` records its name, optional target, existing `ActionInvocation` context state, optional task and generation. Keep action-specific supersession keyed by `(name, target)`, while loading ownership and latest-generation state are tracked per target widget.
+
+Start lifecycle state before executing either a synchronous or asynchronous callback. A new target invocation clears its previous error. Register the replacement before cancelling older invocations so their cleanup cannot briefly clear replacement loading. `supersede=True` cancels only prior invocations with the same name and target; another action sharing that target continues.
+
+Every invocation finishes exactly once, including synchronous success/failure and cancellation. A target keeps `-loading` until all its active invocations finish. Only the newest started generation may publish target error state; an older completion must not overwrite a newer result. Cancellation does not add `-error`. Source-located action/command errors retain their existing reporting behavior; suppressing stale target state does not silently swallow an ordinary callback failure.
+
+Track all callback awaitables, including those without a target. Also track shortcut wrapper tasks created by `start_command`. On close, mark ownership closed first, mark cancelled contexts, request cancellation for every owned task, clear loading/ownership and tolerate later completion callbacks. Do not block synchronous `close()` waiting for arbitrary user code.
+
+Both convenience Apps close their document when exit begins and on unmount; repeated close is harmless. Normal Textual hosts call `bound_document.close()` during shutdown, documented explicitly. Closing `dispatch(message)` ignores queued messages by returning `False`; direct `invoke_command(name)` raises `DocumentStateError`; `start_command(name)` becomes a no-op. These guards run before callbacks or tasks are created. Disabled-command behavior while open remains unchanged.
+
+Public signatures remain `dispatch(message) -> bool`, `invoke_command(name) -> bool`, `start_command(name) -> None` and synchronous `close() -> None`. Existing ActionContext properties remain supported; invocation cancellation state is available for untargeted actions as well.
+
+## 0B — Timer ownership
+
+Keep only active Workers in `RuntimeTimers.workers`. Consume native `Worker.StateChanged` messages in ProjectApp and forward them to its timer owner; do not add automatic markup-event discovery. Remove owned workers on SUCCESS, ERROR or CANCELLED, without suppressing Textual's error handling.
+
+Registration must handle a worker completing immediately, including a fast thread: add it, then check its current terminal state. Close stops handles, requests worker cancellation, and clears both ownership collections. Late state messages are harmless. Repeating ticks still skip overlap; finished worker cleanup must not change when callbacks execute or their source-located errors are reported.
+
+## 0C — Exact numeric sorting
+
+Compare `numbers.Real` values directly rather than coercing to float. Preserve the existing ascending category order: booleans, numeric values, then case-folded textual values; descending reverses that order. Equal keys remain stable. Decimal and other non-Real objects retain their current textual treatment; adding typed column comparators is separate work.
+
+Missing required record fields remain validation errors. `None` retains textual `"None"` ordering. Numeric NaN uses textual `"nan"` ordering, avoiding unordered numeric comparisons; positive and negative infinity remain numeric. Document these policies and test both sort directions. No automatic numeric parsing of seeded string cells is introduced.
+
+Sort construction must complete before rows, cursor or active-sort metadata are mutated. Failed replacement validation preserves the previous table. Preserve the active column/direction on refresh and selected row identity when a declared row key survives.
+
+## Acceptance
+
+- Each R1–R5 reproduction fails before its fix and passes afterward.
+- Sync/async actions and commands, buttons/shortcuts, shared targets, supersession, immediate/delayed failure and shutdown use consistent ownership rules.
+- Cooperative tasks awaiting an Event are cancelled on exit, cannot later resume from that Event, and do not report cancellation as an error.
+- Async and threaded timer runs release completed Workers; overlap skipping and shutdown regressions stay green.
+- Adjacent integers above `2**53`, integers beyond float range, mixed types, NaN, infinities, stable ties and refreshed sorted tables behave as specified.
+- Full committed suite, relevant visual checks, Pyflakes, lock validation, build and CI pass; docs/changelog are updated and review findings addressed.
+
+The launch/validation tooling, compatibility matrix and new widgets have separate roadmap phases and are not included in this implementation.
diff --git a/tests/test_actions.py b/tests/test_actions.py
index 4ea1669..08ae69b 100644
--- a/tests/test_actions.py
+++ b/tests/test_actions.py
@@ -190,3 +190,228 @@ def fail(context):
async with app.run_test() as pilot:
await pilot.click('#button')
assert isinstance(error.value.__cause__, RuntimeError)
+
+
+class _LifecycleHost(App):
+ def __init__(self, markup, actions, metadata):
+ super().__init__()
+ definition = textui.DocumentLoader().from_string(markup, source_name="lifecycle.xml")
+ self.document = definition.bind(self, actions=actions, action_metadata=metadata)
+ self.errors = []
+
+ def compose(self):
+ yield from self.document.compose()
+
+ def _handle_exception(self, error):
+ self.errors.append(error)
+
+ def on_unmount(self):
+ self.document.close()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("fails", [False, True])
+async def test_sync_target_action_finishes_state_and_preserves_error_source(fails):
+ original = ValueError("refresh failed")
+
+ def refresh(context):
+ assert context.target.has_class("-loading")
+ if fails:
+ raise original
+
+ app = _LifecycleHost(
+ '',
+ {"refresh": refresh}, {"refresh": ActionOptions("status")},
+ )
+ async with app.run_test():
+ status = app.document.get_by_id("status")
+ event = Button.Pressed(app.document.get_by_id("button"))
+ if fails:
+ with pytest.raises(textui.ActionExecutionError) as caught:
+ await app.document.dispatch(event)
+ assert caught.value.__cause__ is original
+ assert caught.value.location.source == "lifecycle.xml"
+ assert status.has_class("-error")
+ assert status.textui_error == "refresh failed"
+ else:
+ status.add_class("-error")
+ status.textui_error = "previous error"
+ assert await app.document.dispatch(event)
+ assert not status.has_class("-error")
+ assert status.textui_error is None
+ assert not status.has_class("-loading")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("first_to_finish", [0, 1])
+async def test_different_actions_keep_shared_target_loading_until_both_finish(first_to_finish):
+ releases = [asyncio.Event(), asyncio.Event()]
+ started = [asyncio.Event(), asyncio.Event()]
+
+ async def first(context):
+ started[0].set()
+ await releases[0].wait()
+
+ async def second(context):
+ started[1].set()
+ await releases[1].wait()
+
+ app = _LifecycleHost('''
+
+ ''',
+ {"first": first, "second": second, "sync": lambda context: None},
+ {name: ActionOptions("status", True) for name in ("first", "second", "sync")},
+ )
+ async with app.run_test() as pilot:
+ try:
+ for name in ("first", "second"):
+ await app.document.dispatch(Button.Pressed(app.document.get_by_id(name)))
+ await asyncio.gather(*(event.wait() for event in started))
+ status = app.document.get_by_id("status")
+ await app.document.dispatch(Button.Pressed(app.document.get_by_id("sync")))
+ assert status.has_class("-loading")
+ releases[first_to_finish].set()
+ await pilot.pause()
+ assert status.has_class("-loading")
+ releases[1 - first_to_finish].set()
+ await pilot.pause()
+ assert not status.has_class("-loading")
+ assert not status.has_class("-error")
+ finally:
+ for release in releases:
+ release.set()
+ await pilot.pause()
+ assert app.errors == []
+
+
+@pytest.mark.asyncio
+async def test_textui_shutdown_cancels_untargeted_actions_and_marks_context():
+ started, release, cancelled, finished = (asyncio.Event() for _ in range(4))
+ contexts, effects = [], []
+
+ async def wait(context):
+ contexts.append(context)
+ started.set()
+ try:
+ await release.wait()
+ effects.append("resumed")
+ except asyncio.CancelledError:
+ cancelled.set()
+ raise
+ finally:
+ finished.set()
+
+ app = textui.TextUI(textui.DocumentLoader().from_string(
+ ''), actions={"wait": wait})
+ try:
+ async with app.run_test():
+ await app.document.dispatch(Button.Pressed(app.document.get_by_id("button")))
+ await started.wait()
+ assert cancelled.is_set()
+ assert contexts[0].cancelled
+ release.set()
+ await asyncio.wait_for(finished.wait(), timeout=1)
+ assert effects == []
+ finally:
+ release.set()
+ await asyncio.wait_for(finished.wait(), timeout=1)
+
+
+@pytest.mark.asyncio
+async def test_textui_exit_ignores_queued_action_messages_immediately():
+ calls = []
+ app = textui.TextUI(textui.DocumentLoader().from_string(
+ ''), actions={"go": calls.append})
+ async with app.run_test():
+ event = Button.Pressed(app.document.get_by_id("button"))
+ app.exit()
+ assert await app.document.dispatch(event) is False
+ app.document.close()
+ assert await app.document.dispatch(event) is False
+ assert calls == []
+
+
+@pytest.mark.asyncio
+async def test_superseded_failure_reports_source_without_overwriting_new_target_state():
+ calls = 0
+ cancelled, old_release, new_release = (asyncio.Event() for _ in range(3))
+ contexts = []
+ original = ValueError("stale failure")
+
+ async def refresh(context):
+ nonlocal calls
+ calls += 1
+ contexts.append(context)
+ if calls == 1:
+ try:
+ await old_release.wait()
+ except asyncio.CancelledError:
+ cancelled.set()
+ await old_release.wait()
+ raise original
+ await new_release.wait()
+
+ app = _LifecycleHost(
+ '',
+ {"refresh": refresh}, {"refresh": ActionOptions("status", True)},
+ )
+ async with app.run_test() as pilot:
+ try:
+ button = app.document.get_by_id("button")
+ await app.document.dispatch(Button.Pressed(button))
+ await app.document.dispatch(Button.Pressed(button))
+ await cancelled.wait()
+ assert contexts[0].cancelled
+ assert not contexts[1].cancelled
+ new_release.set()
+ await pilot.pause()
+ old_release.set()
+ await pilot.pause()
+ status = app.document.get_by_id("status")
+ assert not status.has_class("-loading")
+ assert not status.has_class("-error")
+ assert status.textui_error is None
+ assert len(app.errors) == 1
+ assert app.errors[0].__cause__ is original
+ assert app.errors[0].location.source == "lifecycle.xml"
+ finally:
+ old_release.set()
+ new_release.set()
+ await pilot.pause()
+
+
+@pytest.mark.asyncio
+async def test_sync_replacement_supersedes_async_action_without_error_state():
+ contexts = []
+ cancelled, release = asyncio.Event(), asyncio.Event()
+
+ async def pending(context):
+ try:
+ await release.wait()
+ except asyncio.CancelledError:
+ cancelled.set()
+ raise
+
+ def refresh(context):
+ contexts.append(context)
+ return pending(context) if len(contexts) == 1 else None
+
+ app = _LifecycleHost(
+ '',
+ {"refresh": refresh}, {"refresh": ActionOptions("status", True)},
+ )
+ async with app.run_test() as pilot:
+ try:
+ button = app.document.get_by_id("button")
+ await app.document.dispatch(Button.Pressed(button))
+ await app.document.dispatch(Button.Pressed(button))
+ await pilot.pause()
+ assert cancelled.is_set()
+ assert contexts[0].cancelled
+ status = app.document.get_by_id("status")
+ assert not status.has_class("-loading")
+ assert not status.has_class("-error")
+ finally:
+ release.set()
+ await pilot.pause()
+ assert app.errors == []
diff --git a/tests/test_data_widgets.py b/tests/test_data_widgets.py
index e9f389e..35728df 100644
--- a/tests/test_data_widgets.py
+++ b/tests/test_data_widgets.py
@@ -1,4 +1,6 @@
import pytest
+from decimal import Decimal
+from fractions import Fraction
from textual.app import App
from textual.widgets import DataTable, Tree
from textual.coordinate import Coordinate
@@ -482,6 +484,141 @@ async def test_seeded_table_header_sorts_literal_cells():
assert [row.key.value for row in table.ordered_rows] == ["alpha", "zulu"]
+@pytest.mark.asyncio
+@pytest.mark.parametrize("base", [2**53, 10**400], ids=["adjacent-large-integers", "outside-float-range"])
+@pytest.mark.parametrize("keyed", [False, True], ids=["positional-keys", "declared-keys"])
+async def test_runtime_table_sort_preserves_integer_precision_and_refresh_identity(base, keyed):
+ row_key = 'row-key="id"' if keyed else ""
+ app = TextUI(DocumentLoader().from_string(f'''
+
+ Value
+
+ '''))
+ async with app.run_test(size=(40, 8)) as pilot:
+ table = app.document.get_by_id("table")
+ table.set_rows([{"id": "high", "value": base + 1}, {"id": "low", "value": base}])
+ table.move_cursor(row=0, animate=False)
+ await pilot.pause()
+ assert await pilot.click(table, offset=(3, 0))
+ await pilot.pause()
+ assert [table.get_record(row.key.value)["value"] for row in table.ordered_rows] == [base, base + 1]
+ if keyed:
+ assert table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value == "high"
+ assert await pilot.click(table, offset=(3, 0))
+ await pilot.pause()
+ assert [table.get_record(row.key.value)["value"] for row in table.ordered_rows] == [base + 1, base]
+ table.set_rows([
+ {"id": "new", "value": base + 2},
+ {"id": "low", "value": base - 1},
+ {"id": "high", "value": base + 1},
+ ])
+ assert [table.get_record(row.key.value)["value"] for row in table.ordered_rows] == [base + 2, base + 1, base - 1]
+ assert table.columns["value"].label.plain == "Value ↓"
+ if keyed:
+ assert table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value == "high"
+
+
+@pytest.mark.asyncio
+async def test_runtime_numeric_sort_has_deterministic_mixed_nan_and_infinity_order():
+ nan = float("nan")
+ records = [
+ {"id": "none", "value": None}, {"id": "nan", "value": nan},
+ {"id": "alpha", "value": "alpha"}, {"id": "pos_inf", "value": float("inf")},
+ {"id": "huge", "value": 10**400}, {"id": "fraction", "value": 1.5},
+ {"id": "one", "value": 1}, {"id": "neg_inf", "value": float("-inf")},
+ {"id": "true", "value": True}, {"id": "false", "value": False},
+ ]
+ app = TextUI(DocumentLoader().from_string('''
+ Value
+ '''))
+ async with app.run_test() as pilot:
+ table = app.document.get_by_id("table")
+ table.set_rows(records)
+ await pilot.pause()
+ assert await pilot.click(table, offset=(3, 0))
+ await pilot.pause()
+ assert [row.key.value for row in table.ordered_rows] == [
+ "false", "true", "neg_inf", "one", "fraction", "huge", "pos_inf", "alpha", "nan", "none",
+ ]
+ assert table.get_record("nan")["value"] is nan
+ assert table.get_cell("none", "value").plain == ""
+ assert await pilot.click(table, offset=(3, 0))
+ await pilot.pause()
+ assert [row.key.value for row in table.ordered_rows] == [
+ "none", "nan", "alpha", "pos_inf", "huge", "fraction", "one", "neg_inf", "true", "false",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_numeric_ties_are_stable_and_decimal_keeps_textual_ordering():
+ app = TextUI(DocumentLoader().from_string('''
+ Value
+ '''))
+ async with app.run_test() as pilot:
+ table = app.document.get_by_id("table")
+ table.set_rows([
+ {"id": "decimal_two", "value": Decimal("2")},
+ {"id": "float_one", "value": 1.0},
+ {"id": "decimal_ten", "value": Decimal("10")},
+ {"id": "int_one", "value": 1},
+ {"id": "fraction_one", "value": Fraction(1, 1)},
+ {"id": "two", "value": 2},
+ ])
+ await pilot.pause()
+ assert await pilot.click(table, offset=(3, 0))
+ await pilot.pause()
+ assert [row.key.value for row in table.ordered_rows] == [
+ "float_one", "int_one", "fraction_one", "two", "decimal_ten", "decimal_two",
+ ]
+ assert await pilot.click(table, offset=(3, 0))
+ await pilot.pause()
+ assert [row.key.value for row in table.ordered_rows] == [
+ "decimal_two", "decimal_ten", "two", "float_one", "int_one", "fraction_one",
+ ]
+
+
+@pytest.mark.asyncio
+async def test_failed_header_sort_and_rejected_refresh_preserve_rows_cursor_and_sort_state():
+ class UnsortableReal(float):
+ def __float__(self):
+ raise ValueError("cannot compare")
+
+ def __lt__(self, other):
+ raise ValueError("cannot compare")
+
+ app = TextUI(DocumentLoader().from_string('''
+
+ NameValue
+
+ '''))
+ async with app.run_test() as pilot:
+ table = app.document.get_by_id("table")
+ table.set_rows([
+ {"id": "a", "name": "Alpha", "value": UnsortableReal(2)},
+ {"id": "b", "name": "Bravo", "value": UnsortableReal(1)},
+ ])
+ for _ in range(2):
+ name_column = table.columns["name"]
+ table.on_data_table_header_selected(DataTable.HeaderSelected(table, name_column.key, 0, name_column.label))
+ table.move_cursor(row=1, column=1, animate=False)
+ await pilot.pause()
+ cursor = table.cursor_coordinate
+ value_column = table.columns["value"]
+ with pytest.raises(ValueError, match="cannot compare"):
+ table.on_data_table_header_selected(DataTable.HeaderSelected(table, value_column.key, 1, value_column.label))
+ assert [row.key.value for row in table.ordered_rows] == ["b", "a"]
+ assert table.cursor_coordinate == cursor
+ assert table._sort_column == "name" and table._sort_reverse
+ assert table.columns["name"].label.plain == "Name ↓"
+ assert table.columns["value"].label.plain == "Value"
+ with pytest.raises(ValueError, match="missing column 'value'"):
+ table.set_rows([{"id": "bad", "name": "Missing"}])
+ assert [row.key.value for row in table.ordered_rows] == ["b", "a"]
+ assert table.cursor_coordinate == cursor
+ assert table._sort_column == "name" and table._sort_reverse
+ assert table.get_record("a")["name"] == "Alpha"
+
+
def test_data_widgets_can_be_composed_before_app_runs():
bound = DocumentLoader().from_string(MARKUP).bind(App(), actions={
"open_job": lambda context: None,
diff --git a/tests/test_project_app.py b/tests/test_project_app.py
index 37a7b15..7d74fb5 100644
--- a/tests/test_project_app.py
+++ b/tests/test_project_app.py
@@ -7,6 +7,7 @@
from textual.widgets import Button
from textui import DocumentStateError, DocumentValidationError
+from textui.errors import ActionExecutionError
from textui.project import ProjectSource
from textui.project_app import ProjectApp
@@ -19,6 +20,216 @@ def project(tmp_path: Path, markup: str, script: str) -> ProjectSource:
return ProjectSource.discover(tmp_path / "app.ui")
+@pytest.mark.asyncio
+@pytest.mark.parametrize("entry", ["command", "event"])
+@pytest.mark.parametrize("fails", [False, True])
+async def test_sync_target_command_finishes_state_and_preserves_error_source(tmp_path, entry, fails):
+ source = project(tmp_path, '', '''
+from textui import command
+@command(target="status")
+def refresh():
+ window.app.was_loading = window.document.get_by_id("status").has_class("-loading")
+ if window.app.fails:
+ raise window.app.original_error
+''')
+ app = ProjectApp(source)
+ app.fails = fails
+ app.original_error = ValueError("refresh failed")
+ async with app.run_test():
+ status = app.document.get_by_id("status")
+ status.add_class("-error")
+ status.textui_error = "old error"
+ invoke = app.document.invoke_command("refresh") if entry == "command" else app.document.dispatch(
+ Button.Pressed(app.document.get_by_id("button")))
+ if fails:
+ with pytest.raises(ActionExecutionError) as caught:
+ await invoke
+ assert caught.value.__cause__ is app.original_error
+ expected_source = "controller.py" if entry == "command" else "app.ui"
+ assert caught.value.location.source.endswith(expected_source)
+ assert status.has_class("-error")
+ assert status.textui_error == "refresh failed"
+ else:
+ assert await invoke
+ assert not status.has_class("-error")
+ assert status.textui_error is None
+ assert app.was_loading
+ assert not status.has_class("-loading")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["action", "command"])
+async def test_project_shutdown_cancels_untargeted_action_and_command(tmp_path, kind):
+ signature = "context" if kind == "action" else ""
+ source = project(tmp_path, '', f'''
+import asyncio
+from textui import {kind}
+@{kind}()
+async def wait({signature}):
+ window.app.started.set()
+ try:
+ await window.app.release.wait()
+ window.app.effects.append(window.phase)
+ except asyncio.CancelledError:
+ window.app.cancelled.set()
+ raise
+ finally:
+ window.app.finished.set()
+''')
+ app = ProjectApp(source)
+ app.started, app.release, app.cancelled, app.finished = (asyncio.Event() for _ in range(4))
+ app.effects = []
+ try:
+ async with app.run_test():
+ if kind == "action":
+ await app.document.dispatch(Button.Pressed(app.document.get_by_id("button")))
+ else:
+ app.document.start_command("wait")
+ await app.started.wait()
+ assert app.window.phase == "closed"
+ assert app.cancelled.is_set()
+ app.release.set()
+ await asyncio.wait_for(app.finished.wait(), timeout=1)
+ assert app.effects == []
+ finally:
+ app.release.set()
+ await asyncio.wait_for(app.finished.wait(), timeout=1)
+
+
+@pytest.mark.asyncio
+async def test_close_before_shortcut_wrapper_starts_and_closed_document_rejects_new_work(tmp_path):
+ source = project(tmp_path, '', '''
+from textui import command
+@command(shortcut="ctrl+r")
+def refresh():
+ window.app.calls.append("called")
+''')
+ app = ProjectApp(source)
+ app.calls = []
+ async with app.run_test() as pilot:
+ button = app.document.get_by_id("button")
+ app.document.start_command("refresh")
+ app.document.close()
+ app.document.close()
+ await pilot.pause()
+ assert app.calls == []
+ assert await app.document.dispatch(Button.Pressed(button)) is False
+ with pytest.raises(DocumentStateError, match="closed"):
+ await app.document.invoke_command("refresh")
+ tasks = asyncio.all_tasks()
+ app.document.start_command("refresh")
+ assert asyncio.all_tasks() == tasks
+ await pilot.pause()
+ assert app.calls == []
+
+
+@pytest.mark.asyncio
+async def test_project_exit_ignores_queued_events_before_unmount(tmp_path):
+ source = project(tmp_path, '', '''
+from textui import action
+@action
+def refresh(context):
+ window.app.calls.append("called")
+''')
+ app = ProjectApp(source)
+ app.calls = []
+ async with app.run_test():
+ event = Button.Pressed(app.document.get_by_id("button"))
+ app.exit()
+ assert app.window.phase == "closing"
+ assert await app.document.dispatch(event) is False
+ assert app.calls == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("first_to_finish", [0, 1])
+async def test_action_and_command_share_target_loading_in_both_completion_orders(tmp_path, first_to_finish):
+ source = project(tmp_path, '', '''
+from textui import action, command
+@action(target="status", supersede=True)
+async def first(context):
+ window.app.started[0].set()
+ await window.app.releases[0].wait()
+@command(target="status", supersede=True)
+async def second():
+ window.app.started[1].set()
+ await window.app.releases[1].wait()
+''')
+ app = ProjectApp(source)
+ app.started = [asyncio.Event(), asyncio.Event()]
+ app.releases = [asyncio.Event(), asyncio.Event()]
+ async with app.run_test() as pilot:
+ await app.document.dispatch(Button.Pressed(app.document.get_by_id("button")))
+ command_task = asyncio.create_task(app.document.invoke_command("second"))
+ try:
+ await asyncio.gather(*(event.wait() for event in app.started))
+ status = app.document.get_by_id("status")
+ app.releases[first_to_finish].set()
+ if first_to_finish == 1:
+ assert await command_task
+ await pilot.pause()
+ assert status.has_class("-loading")
+ app.releases[1 - first_to_finish].set()
+ assert await command_task
+ await pilot.pause()
+ assert not status.has_class("-loading")
+ assert not status.has_class("-error")
+ finally:
+ for release in app.releases:
+ release.set()
+ await asyncio.gather(command_task, return_exceptions=True)
+ await pilot.pause()
+
+
+@pytest.mark.asyncio
+async def test_superseded_command_failure_does_not_publish_stale_target_error(tmp_path):
+ source = project(tmp_path, '', '''
+import asyncio
+from textui import command
+@command(target="status", supersede=True)
+async def refresh():
+ index = window.app.calls
+ window.app.calls += 1
+ window.app.started[index].set()
+ if index == 0:
+ try:
+ await window.app.releases[0].wait()
+ except asyncio.CancelledError:
+ window.app.cancelled.set()
+ await window.app.releases[0].wait()
+ raise window.app.original_error
+ await window.app.releases[1].wait()
+''')
+ app = ProjectApp(source)
+ app.calls = 0
+ app.started = [asyncio.Event(), asyncio.Event()]
+ app.releases = [asyncio.Event(), asyncio.Event()]
+ app.cancelled = asyncio.Event()
+ app.original_error = ValueError("stale failure")
+ async with app.run_test():
+ first = asyncio.create_task(app.document.invoke_command("refresh"))
+ await app.started[0].wait()
+ second = asyncio.create_task(app.document.invoke_command("refresh"))
+ try:
+ await app.started[1].wait()
+ await app.cancelled.wait()
+ app.releases[1].set()
+ assert await second
+ app.releases[0].set()
+ with pytest.raises(ActionExecutionError) as caught:
+ await first
+ assert caught.value.__cause__ is app.original_error
+ assert caught.value.location.source.endswith("controller.py")
+ status = app.document.get_by_id("status")
+ assert not status.has_class("-loading")
+ assert not status.has_class("-error")
+ assert status.textui_error is None
+ finally:
+ for release in app.releases:
+ release.set()
+ await asyncio.gather(first, second, return_exceptions=True)
+
+
@pytest.mark.asyncio
async def test_window_copy_requires_ready_and_selection_action_uses_clipboard(tmp_path, monkeypatch):
from unittest.mock import AsyncMock
diff --git a/tests/test_project_timers.py b/tests/test_project_timers.py
index 495b658..6730999 100644
--- a/tests/test_project_timers.py
+++ b/tests/test_project_timers.py
@@ -1,17 +1,19 @@
import asyncio
from pathlib import Path
+import threading
import pytest
+from textual.worker import Worker, WorkerCancelled, WorkerFailed, WorkerState, get_current_worker
-from textui import DocumentStateError, every
+from textui import DocumentStateError, TextUIError, every
from textui.project import ProjectSource
from textui.project_app import ProjectApp
-def app_from(tmp_path: Path, script: str) -> ProjectApp:
+def app_from(tmp_path: Path, script: str, app_type=ProjectApp) -> ProjectApp:
(tmp_path / "app.ui").write_text('', encoding="utf-8")
(tmp_path / "controller.py").write_text(script, encoding="utf-8")
- app = ProjectApp(ProjectSource.discover(tmp_path / "app.ui"))
+ app = app_type(ProjectSource.discover(tmp_path / "app.ui"))
app.events = []
return app
@@ -177,3 +179,166 @@ def touch_status() -> None:
await pilot.pause(0.05)
assert len(app.events) == before, "timer fired after the message pump stopped"
app._running = True
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("thread", [False, True])
+async def test_completed_timer_workers_are_released_after_twenty_ticks(tmp_path, thread):
+ app = app_from(tmp_path, "")
+ completed = asyncio.Event()
+ observed = []
+
+ def record(worker):
+ observed.append(worker)
+ if len(observed) >= 20:
+ handle.stop()
+ completed.set()
+
+ async def asynchronous():
+ record(get_current_worker())
+ await asyncio.sleep(0)
+
+ def threaded():
+ app.call_from_thread(record, get_current_worker())
+
+ async with app.run_test() as pilot:
+ handle = app.window.every(0.001, threaded if thread else asynchronous, thread=thread)
+ await asyncio.wait_for(completed.wait(), timeout=3)
+ await asyncio.gather(*(worker.wait() for worker in observed))
+ await pilot.pause()
+ assert len(observed) >= 20
+ assert all(worker.is_finished for worker in observed)
+ assert app.window.timers.workers == set()
+ app.window.timers.close()
+ app.window.timers.close()
+ assert app.window.timers.handles == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("thread", [False, True])
+async def test_timer_registration_releases_a_worker_that_already_finished(tmp_path, thread):
+ app = app_from(tmp_path, "")
+
+ async def asynchronous():
+ return None
+
+ async with app.run_test() as pilot:
+ worker = app.run_worker((lambda: None) if thread else asynchronous(), thread=thread)
+ await worker.wait()
+ app.window.timers._own_worker(worker)
+ assert app.window.timers.workers == set()
+ await pilot.pause()
+ assert app.window.timers.workers == set()
+
+
+@pytest.mark.asyncio
+async def test_timer_close_releases_active_workers_and_ignores_unrelated_and_late_messages(tmp_path):
+ app = app_from(tmp_path, "")
+ started, release = asyncio.Event(), asyncio.Event()
+ observed = []
+
+ async def tick():
+ observed.append(get_current_worker())
+ started.set()
+ await release.wait()
+
+ async def unrelated():
+ return "host work"
+
+ async with app.run_test() as pilot:
+ app.window.after(0.001, tick)
+ await asyncio.wait_for(started.wait(), timeout=1)
+ owned = observed[0]
+ host_worker = app.run_worker(unrelated())
+ assert await host_worker.wait() == "host work"
+ await pilot.pause()
+ assert app.window.timers.workers == {owned}
+ app.window.timers.close()
+ app.window.timers.close()
+ assert app.window.timers.handles == []
+ assert app.window.timers.workers == set()
+ with pytest.raises(WorkerCancelled):
+ await owned.wait()
+ await pilot.pause()
+ app.window.timers.handle_worker_state(Worker.StateChanged(owned, WorkerState.CANCELLED))
+ app.window.timers.handle_worker_state(Worker.StateChanged(host_worker, WorkerState.SUCCESS))
+ assert app.window.timers.workers == set()
+
+
+class _CapturingTimerApp(ProjectApp):
+ def __init__(self, source):
+ super().__init__(source)
+ self.errors = []
+
+ def _handle_exception(self, error):
+ self.errors.append(error)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("thread", [False, True])
+async def test_cancelled_timer_workers_are_released_before_app_close(tmp_path, thread):
+ app = app_from(tmp_path, "", _CapturingTimerApp)
+ started = asyncio.Event()
+ release = threading.Event() if thread else asyncio.Event()
+ observed = []
+
+ def record(worker):
+ observed.append(worker)
+ started.set()
+
+ async def asynchronous():
+ record(get_current_worker())
+ await release.wait()
+
+ def threaded():
+ app.call_from_thread(record, get_current_worker())
+ release.wait()
+
+ async with app.run_test() as pilot:
+ try:
+ app.window.timers.schedule(0.001, threaded if thread else asynchronous, repeat=False, thread=thread)
+ await asyncio.wait_for(started.wait(), timeout=1)
+ worker = observed[0]
+ worker.cancel()
+ with pytest.raises(WorkerCancelled):
+ await worker.wait()
+ await pilot.pause()
+ assert app.window.timers.workers == set()
+ assert app.errors == []
+ finally:
+ release.set()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("thread", [False, True])
+async def test_failed_timer_workers_are_released_without_suppressing_native_errors(tmp_path, thread):
+ app = app_from(tmp_path, "", _CapturingTimerApp)
+ started = asyncio.Event()
+ observed = []
+ original = ValueError("timer failed")
+
+ def record(worker):
+ observed.append(worker)
+ started.set()
+
+ async def asynchronous():
+ record(get_current_worker())
+ raise original
+
+ def threaded():
+ app.call_from_thread(record, get_current_worker())
+ raise original
+
+ async with app.run_test() as pilot:
+ app.window.timers.schedule(0.001, threaded if thread else asynchronous, repeat=False, thread=thread)
+ await asyncio.wait_for(started.wait(), timeout=1)
+ with pytest.raises(WorkerFailed):
+ await observed[0].wait()
+ await pilot.pause()
+ assert app.window.timers.workers == set()
+ assert len(app.errors) == 1
+ assert isinstance(app.errors[0], WorkerFailed)
+ error = app.errors[0].error
+ assert isinstance(error, TextUIError)
+ assert error.__cause__ is original
+ assert error.location.source == __file__
diff --git a/textui/document.py b/textui/document.py
index bd8936e..ec5f8c2 100644
--- a/textui/document.py
+++ b/textui/document.py
@@ -15,7 +15,8 @@
from textual.widget import Widget
from textual.widgets import Button, Checkbox, Input, RadioButton, RadioSet, Select, Switch, TabbedContent, TabPane, TextArea
-from .actions import ActionCallback, ActionContext, ActionInvocation
+from .actions import ActionCallback, ActionContext
+from .invocations import InvocationOwner
from .errors import (
ActionExecutionError, ComponentBuildError, DocumentStateError,
DocumentStyleError, DocumentValidationError, ElementNotFoundError,
@@ -130,9 +131,8 @@ def __init__(
}
self._widgets: dict[str, Widget] = {}
self._bindings: dict[type, list[tuple[Widget, EventSpec, str, ElementNode]]] = {}
- self._action_tasks: set[asyncio.Future[object]] = set()
- self._lifecycle_tasks: dict[tuple[str, str], set[asyncio.Future[object]]] = {}
- self._lifecycle_invocations: dict[asyncio.Future[object], ActionInvocation] = {}
+ self._invocations = InvocationOwner()
+ self._command_tasks: set[asyncio.Future[object]] = set()
self._modal_nodes = {
node.common["id"]: node
for node in definition.nodes
@@ -422,20 +422,14 @@ def dismiss_modal(self, value: object | None = None) -> None:
screen.dismiss(value)
def close(self) -> None:
- """Cancel lifecycle work and clear loading state during application shutdown."""
+ """Reject new work, cancel owned tasks, and clear loading during shutdown."""
+ self._invocations.close()
self._autofocus_closed = True
self._autofocus_pending.clear()
- for (_name, target_id), tasks in tuple(self._lifecycle_tasks.items()):
- for task in tasks:
- if not task.done():
- task.cancel()
- invocation = self._lifecycle_invocations.pop(task, None)
- if invocation is not None:
- invocation.cancelled = True
- target = self._widgets.get(target_id)
- if target is not None:
- target.remove_class("-loading")
- self._lifecycle_tasks.clear()
+ for task in tuple(self._command_tasks):
+ if not task.done():
+ task.cancel()
+ self._command_tasks.clear()
def get_by_id(self, element_id: str) -> Widget:
"""Look up declared IDs only, and only while the widget is mounted."""
@@ -448,68 +442,32 @@ def get_by_id(self, element_id: str) -> Widget:
async def invoke_command(self, name: str) -> bool:
"""Run an enabled declared command, returning whether it ran."""
+ if self._invocations.closed:
+ raise DocumentStateError("Document is closed")
command = self.commands.get(name)
if command is None:
raise DocumentStateError(f"No declared command {name!r}")
if not command.enabled:
return False
+ invocation = None
try:
+ options = self.action_metadata.get(name)
+ target_id = getattr(options, "target", None)
+ target = self.get_by_id(target_id) if target_id is not None else None
+ invocation = self._invocations.begin(name, target, supersede=getattr(options, "supersede", False))
result = self._command_callbacks[name]()
if isawaitable(result):
- options = self.action_metadata.get(name)
- target_id = getattr(options, "target", None)
- target = self.get_by_id(target_id) if target_id is not None else None
- key = (name, target_id) if target_id is not None else None
- if target is not None:
- target.add_class("-loading")
- target.remove_class("-error")
- target.textui_error = None
-
- if key is not None and getattr(options, "supersede", False):
- for previous in tuple(self._lifecycle_tasks.get(key, ())):
- if previous.done():
- continue
- previous_invocation = self._lifecycle_invocations.get(previous)
- if previous_invocation is not None:
- previous_invocation.cancelled = True
- previous.cancel()
-
task = asyncio.ensure_future(result)
- if key is not None:
- self._lifecycle_tasks.setdefault(key, set()).add(task)
- self._lifecycle_invocations[task] = ActionInvocation(target)
-
- def finish_lifecycle(error: Exception | None = None) -> None:
- if key is None:
- return
- tasks = self._lifecycle_tasks.get(key)
- if tasks is None:
- return
- tasks.discard(task)
- self._lifecycle_invocations.pop(task, None)
- if target is not None:
- if error is not None:
- target.textui_error = str(error)
- target.add_class("-error")
- if not tasks:
- target.remove_class("-loading")
- if not tasks:
- self._lifecycle_tasks.pop(key, None)
-
- try:
- await task
- except asyncio.CancelledError:
- finish_lifecycle()
- raise
- except Exception as error:
- finish_lifecycle(error)
- raise ActionExecutionError(
- f"Command {name!r} failed: {error}",
- location=self._command_locations.get(name),
- value=name,
- ) from error
- finish_lifecycle()
+ self._invocations.track(invocation, task)
+ await task
+ except asyncio.CancelledError:
+ if invocation is not None:
+ invocation.state.cancelled = True
+ self._invocations.finish(invocation)
+ raise
except Exception as error:
+ if invocation is not None:
+ self._invocations.finish(invocation, error)
if isinstance(error, ActionExecutionError):
raise
raise ActionExecutionError(
@@ -517,15 +475,18 @@ def finish_lifecycle(error: Exception | None = None) -> None:
location=self._command_locations.get(name),
value=name,
) from error
+ self._invocations.finish(invocation)
return True
def start_command(self, name: str) -> None:
"""Schedule a command without holding up Textual's input dispatcher."""
+ if self._invocations.closed:
+ return
task = asyncio.ensure_future(self.invoke_command(name))
- self._action_tasks.add(task)
+ self._command_tasks.add(task)
def report_command_result(completed: asyncio.Future[object]) -> None:
- self._action_tasks.discard(completed)
+ self._command_tasks.discard(completed)
if completed.cancelled():
return
try:
@@ -537,6 +498,8 @@ def report_command_result(completed: asyncio.Future[object]) -> None:
async def dispatch(self, message: Message) -> bool:
"""Await one exact-type/identity action without altering native bubbling."""
+ if self._invocations.closed:
+ return False
if isinstance(message, TabbedContent.TabActivated):
self._focus_autofocus_in_tab(message.pane)
for widget, event, name, node in self._bindings.get(type(message), ()):
@@ -544,72 +507,23 @@ async def dispatch(self, message: Message) -> bool:
continue
if name in self.commands and not self.commands[name].enabled:
return True
+ invocation = None
try:
options = self.action_metadata.get(name)
target_id = getattr(options, "target", None)
target = self.get_by_id(target_id) if target_id is not None else None
- key = (name, target_id) if target_id is not None else None
- if target is not None:
- target.add_class("-loading")
- target.remove_class("-error")
- target.textui_error = None
- invocation = ActionInvocation(target) if target is not None else None
- result = self.actions[name](ActionContext(message, widget, self.app, self, invocation))
+ invocation = self._invocations.begin(name, target, supersede=getattr(options, "supersede", False))
+ result = self.actions[name](ActionContext(message, widget, self.app, self, invocation.state))
if isawaitable(result):
- if key is not None and getattr(options, "supersede", False):
- for previous in tuple(self._lifecycle_tasks.get(key, ())):
- if previous.done():
- continue
- previous_invocation = self._lifecycle_invocations.get(previous)
- if previous_invocation is not None:
- previous_invocation.cancelled = True
- previous.cancel()
task = asyncio.ensure_future(result)
- if key is not None:
- self._lifecycle_tasks.setdefault(key, set()).add(task)
- if invocation is not None:
- self._lifecycle_invocations[task] = invocation
-
- def finish_lifecycle(completed: asyncio.Future[object], error: Exception | None = None) -> None:
- if key is None:
- return
- tasks = self._lifecycle_tasks.get(key)
- if tasks is None:
- return
- tasks.discard(completed)
- self._lifecycle_invocations.pop(completed, None)
- if target is not None:
- if error is not None:
- target.textui_error = str(error)
- target.add_class("-error")
- if not tasks:
- target.remove_class("-loading")
- if not tasks:
- self._lifecycle_tasks.pop(key, None)
-
- await asyncio.sleep(0)
- if task.done():
- try:
- await task
- except asyncio.CancelledError:
- finish_lifecycle(task)
- raise
- except Exception as error:
- finish_lifecycle(task, error)
- raise
- finish_lifecycle(task)
- return True
- self._action_tasks.add(task)
+ self._invocations.track(invocation, task)
def report_action_result(completed: asyncio.Future[object]) -> None:
- self._action_tasks.discard(completed)
if completed.cancelled():
- finish_lifecycle(completed)
return
try:
completed.result()
except Exception as error:
- finish_lifecycle(completed, error)
action_error = ActionExecutionError(
f'Action {name!r} failed: {error}',
location=node.location,
@@ -617,11 +531,27 @@ def report_action_result(completed: asyncio.Future[object]) -> None:
)
action_error.__cause__ = error
self.app._handle_exception(action_error)
- else:
- finish_lifecycle(completed)
- task.add_done_callback(report_action_result)
+ try:
+ await asyncio.sleep(0)
+ except asyncio.CancelledError:
+ task.add_done_callback(report_action_result)
+ raise
+ if not task.done():
+ task.add_done_callback(report_action_result)
+ return True
+ await task
+ except asyncio.CancelledError:
+ if invocation is not None:
+ invocation.state.cancelled = True
+ # Pending work still owns its target until it actually finishes.
+ if invocation.task is None or invocation.task.done():
+ self._invocations.finish(invocation)
+ raise
except Exception as error:
+ if invocation is not None:
+ self._invocations.finish(invocation, error)
raise ActionExecutionError(f'Action {name!r} failed: {error}', location=node.location, value=name) from error
+ self._invocations.finish(invocation)
return True
return False
diff --git a/textui/invocations.py b/textui/invocations.py
new file mode 100644
index 0000000..6523046
--- /dev/null
+++ b/textui/invocations.py
@@ -0,0 +1,118 @@
+"""Private ownership of action and command work within a bound document."""
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+
+from textual.widget import Widget
+
+from .actions import ActionInvocation
+from .errors import DocumentStateError
+
+
+@dataclass(eq=False, slots=True)
+class OwnedInvocation:
+ name: str
+ state: ActionInvocation
+ generation: int
+ task: asyncio.Future[object] | None = None
+ finished: bool = False
+
+
+class InvocationOwner:
+ def __init__(self) -> None:
+ self._closed = False
+ self._generation = 0
+ self._active: set[OwnedInvocation] = set()
+ self._keys: dict[tuple[str, Widget | None], set[OwnedInvocation]] = {}
+ self._targets: dict[Widget, set[OwnedInvocation]] = {}
+ self._latest: dict[Widget, int] = {}
+
+ @property
+ def closed(self) -> bool:
+ return self._closed
+
+ def begin(self, name: str, target: Widget | None, *, supersede: bool) -> OwnedInvocation:
+ if self.closed:
+ raise DocumentStateError("Document is closed")
+ self._generation += 1
+ invocation = OwnedInvocation(name, ActionInvocation(target), self._generation)
+ key = (name, target)
+ previous = tuple(self._keys.get(key, ())) if supersede else ()
+ self._active.add(invocation)
+ self._keys.setdefault(key, set()).add(invocation)
+ if target is not None:
+ self._targets.setdefault(target, set()).add(invocation)
+ self._latest[target] = invocation.generation
+ target.add_class("-loading")
+ target.remove_class("-error")
+ target.textui_error = None
+ # Register replacement ownership before requesting old-task cancellation.
+ for old in previous:
+ if old.task is not None and old.task.done():
+ continue
+ old.state.cancelled = True
+ if old.task is not None:
+ old.task.cancel()
+ return invocation
+
+ def track(self, invocation: OwnedInvocation, task: asyncio.Future[object]) -> None:
+ if not invocation.finished:
+ invocation.task = task
+ if self.closed or invocation.state.cancelled:
+ task.cancel()
+
+ def completed(future: asyncio.Future[object]) -> None:
+ if future.cancelled():
+ invocation.state.cancelled = True
+ self.finish(invocation)
+ else:
+ error = future.exception()
+ self.finish(invocation, error if isinstance(error, Exception) else None)
+
+ task.add_done_callback(completed)
+
+ def finish(self, invocation: OwnedInvocation, error: Exception | None = None) -> None:
+ if invocation.finished:
+ return
+ invocation.finished = True
+ invocation.task = None
+ self._active.discard(invocation)
+ target = invocation.state.target
+ key = (invocation.name, target)
+ keyed = self._keys.get(key)
+ if keyed is not None:
+ keyed.discard(invocation)
+ if not keyed:
+ self._keys.pop(key)
+ if target is None:
+ return
+ active = self._targets.get(target)
+ if active is None:
+ return
+ active.discard(invocation)
+ if error is not None and self._latest.get(target) == invocation.generation:
+ target.textui_error = str(error)
+ target.add_class("-error")
+ if not active:
+ target.remove_class("-loading")
+ self._targets.pop(target)
+ self._latest.pop(target, None)
+
+ def close(self) -> None:
+ if self.closed:
+ return
+ self._closed = True
+ active = tuple(self._active)
+ for invocation in active:
+ invocation.state.cancelled = True
+ invocation.finished = True
+ if invocation.task is not None and not invocation.task.done():
+ invocation.task.cancel()
+ invocation.task = None
+ for target in self._targets:
+ target.remove_class("-loading")
+ self._active.clear()
+ self._keys.clear()
+ self._targets.clear()
+ self._latest.clear()
diff --git a/textui/project_app.py b/textui/project_app.py
index 678e70e..587cede 100644
--- a/textui/project_app.py
+++ b/textui/project_app.py
@@ -6,6 +6,7 @@
from textual import on
from textual.app import App, ComposeResult
+from textual.worker import Worker
from textual.widgets import Button, Checkbox, Collapsible, DataTable, Input, RadioButton, RadioSet, Select, Switch, TabbedContent, TextArea, Tree
from .widgets.split import Split
from .widgets.navigation import Nav
@@ -111,6 +112,10 @@ async def on_mount(self) -> None:
async def on_unmount(self) -> None:
await self._close_once()
+ @on(Worker.StateChanged)
+ def forward_timer_worker_state(self, event: Worker.StateChanged) -> None:
+ self.window.timers.handle_worker_state(event)
+
def _check_resize(self) -> None:
"""Queue the hook after Textual forwards its debounced resize to the screen."""
resize = self._resize_event
@@ -143,10 +148,12 @@ async def _run_resize_hook(self, screen: Any, generation: int, width: int, heigh
await self.controllers.hook("on_resize", width, height)
def exit(self, result: Any = None, return_code: int = 0, message: Any = None) -> None:
- """Stop project-owned timers before Textual starts application teardown."""
- if self.window.phase == "ready":
+ """Stop project-owned work before Textual starts application teardown."""
+ if self.window.phase != "closed":
self.window.phase = "closing"
self.window.timers.close()
+ if self.document is not None:
+ self.document.close()
super().exit(result=result, return_code=return_code, message=message)
async def _close_once(self) -> None:
diff --git a/textui/textui.py b/textui/textui.py
index 17a82ab..c49e43e 100644
--- a/textui/textui.py
+++ b/textui/textui.py
@@ -32,6 +32,13 @@ def compose(self) -> ComposeResult:
def action_textui_activate_tab(self, pane_id: str) -> None:
activate_tab(self.document, pane_id)
+ def exit(self, result: Any = None, return_code: int = 0, message: Any = None) -> None:
+ self.document.close()
+ super().exit(result=result, return_code=return_code, message=message)
+
+ def on_unmount(self) -> None:
+ self.document.close()
+
@on(Button.Pressed)
@on(Input.Changed)
@on(Input.Submitted)
diff --git a/textui/timers.py b/textui/timers.py
index b2668c4..d9ad992 100644
--- a/textui/timers.py
+++ b/textui/timers.py
@@ -7,6 +7,8 @@
from pathlib import Path
from typing import Any
+from textual.worker import Worker, WorkerState
+
from .errors import DocumentStateError, SourceLocation, TextUIError
@@ -36,7 +38,7 @@ def __init__(self, app: Any, window: Any) -> None:
self.app = app
self.window = window
self.handles: list[Any] = []
- self.workers: set[Any] = set()
+ self.workers: set[Worker[Any]] = set()
def schedule(self, seconds: float, callback: Callable[[], Any], *, repeat: bool, thread: bool = False):
if self.window.phase != "ready":
@@ -73,7 +75,7 @@ def invoke() -> Any:
if thread:
worker = self.app.run_worker(invoke, name=f"textui:{name}", thread=True)
- self.workers.add(worker)
+ self._own_worker(worker)
return
try:
result = invoke()
@@ -90,7 +92,7 @@ async def await_result():
finally:
busy = False
worker = self.app.run_worker(await_result(), name=f"textui:{name}")
- self.workers.add(worker)
+ self._own_worker(worker)
return
busy = False
@@ -98,8 +100,22 @@ async def await_result():
self.handles.append(handle)
return handle
+ def _own_worker(self, worker: Worker[Any]) -> None:
+ self.workers.add(worker)
+ # Terminal messages may precede registration for immediately finished work.
+ if worker.is_finished:
+ self.workers.discard(worker)
+
+ def handle_worker_state(self, event: Worker.StateChanged) -> None:
+ if event.worker in self.workers and event.state in {
+ WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED,
+ }:
+ self.workers.discard(event.worker)
+
def close(self) -> None:
- for handle in self.handles:
+ for handle in tuple(self.handles):
handle.stop()
- for worker in self.workers:
+ self.handles.clear()
+ for worker in tuple(self.workers):
worker.cancel()
+ self.workers.clear()
diff --git a/textui/widgets/data_widgets.py b/textui/widgets/data_widgets.py
index 09229cd..71bdfea 100644
--- a/textui/widgets/data_widgets.py
+++ b/textui/widgets/data_widgets.py
@@ -295,11 +295,19 @@ def set_rows(self, rows: Iterable[Mapping[str, object]]) -> None:
self._replace_rows(validated)
- def _replace_rows(self, validated: list[tuple[str, Mapping[str, object], tuple[Text, ...]]]) -> None:
- if self._sort_column is not None:
+ def _replace_rows(
+ self,
+ validated: list[tuple[str, Mapping[str, object], tuple[Text, ...]]],
+ *,
+ sort_column: str | None = None,
+ reverse: bool | None = None,
+ ) -> None:
+ column = self._sort_column if sort_column is None else sort_column
+ direction = self._sort_reverse if reverse is None else reverse
+ if column is not None:
validated.sort(
- key=lambda row: self._sort_value(row[1][self._sort_column]),
- reverse=self._sort_reverse,
+ key=lambda row: self._sort_value(row[1][column]),
+ reverse=direction,
)
previous_key: str | None = None
previous_column: int | None = None
@@ -326,39 +334,35 @@ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None
return
reverse = column_key == self._sort_column and not self._sort_reverse
if self._runtime_records:
- self._sort_column = column_key
- self._sort_reverse = reverse
- if self.row_key_field is None:
- self._replace_rows([
- (
- key,
- record,
- tuple(
- Text("" if record[column.key] is None else str(record[column.key]), justify=column.align)
- for column in self._seed_columns
- ),
- )
- for key, record in self._runtime_records.items()
- ])
- else:
- self.set_rows(self._runtime_records.values())
+ self._replace_rows([
+ (
+ key,
+ record,
+ tuple(
+ Text("" if record[column.key] is None else str(record[column.key]), justify=column.align)
+ for column in self._seed_columns
+ ),
+ )
+ for key, record in self._runtime_records.items()
+ ], sort_column=column_key, reverse=reverse)
else:
self.sort(
event.column_key,
key=lambda value: self._sort_value(value.plain if isinstance(value, Text) else value),
reverse=reverse,
)
- self._sort_column = column_key
- self._sort_reverse = reverse
+ self._sort_column = column_key
+ self._sort_reverse = reverse
self._set_sort_indicator(column_key, reverse)
event.stop()
@staticmethod
- def _sort_value(value: object) -> tuple[int, float | str]:
+ def _sort_value(value: object) -> tuple[int, Real | str]:
if isinstance(value, bool):
- return (0, float(value))
- if isinstance(value, Real):
- return (1, float(value))
+ return (0, value)
+ # Self-comparison recognizes NaN without overflowing arbitrarily large ints.
+ if isinstance(value, Real) and value == value:
+ return (1, value)
return (2, str(value).casefold())
def _set_sort_indicator(self, column_key: str, reverse: bool) -> None: