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( + '