diff --git a/evidence/refresh-callback-task-owner-20261006/README.md b/evidence/refresh-callback-task-owner-20261006/README.md new file mode 100644 index 0000000000..6adae448bd --- /dev/null +++ b/evidence/refresh-callback-task-owner-20261006/README.md @@ -0,0 +1,50 @@ +# Refresh callback execution belongs to its sender + +Screen retained each callback with its sender, checked that sender's paint, then +awaited the callback on Screen's task inside the sender's context. An async +callback could wait for its sender's next refresh: wait_for_refresh saw a foreign +task, queued another Screen callback, and waited while Screen was still awaiting +the first callback. One async widget callback could also prevent unrelated +senders' paint and callback admission. + +Screen now owns admission only. It hands each admitted callback to the sender's +existing call_later / events.Callback / on_callback path. That original pump owns +execution, self-wait refusal, errors and teardown. No new task or queue is added. +Held callbacks remain in Screen's same queue; the original subtree, whole-frame, +batch and backdrop checks remain. One synchronous drain borrows one preparation +cohort; the repeated preparation and foreign callback context/invocation were +deleted. No preparation is acquired for an empty or batched callback queue. + +Existing Package parser covered 249 native production modules, 463 native tests +and 288 Toad dependency modules with zero omissions. before.json records 208 +lexical declaration/consumer sites. External dynamic callback targets remain +unresolved. Toad sources were read, not edited. + +This source defect is not established as physical04's cause. The original footage +shows saved transcript paint. A callback held behind native readiness does not +itself suppress the capture helper's independent asyncio timeout; its missing +DTO/error still needs Parent's helper trace. Original recordings, helpers, +wheels and failed capture remain unchanged. + +## Changed-path qualification + +`PYTHONPATH=src PYTHONDONTWRITEBYTECODE=1 /usr/bin/python -m pytest -q +tests/test_refresh_callback_owner.py tests/test_lazy.py::test_lazy +tests/test_reparent.py::test_reparent_preserves_order_and_transfers_pending_frame_callbacks +--tb=short`: **5 passed in 0.79s**, terminal exit 0. + +The three new real run_async/HeadlessDriver cases confirm App/widget callbacks run +on their own actual tasks, retain the original self-wait refusal and allow another +sender's actual paint/callback while suspended. Held subtree, Screen and App +callbacks remain queued until the original roots release. The two existing cases +cover the directly affected async Lazy mount/self-removal and original queued +callback transfer on reparent. No old native73/74 or physical check was repeated. + +after.json covers 249 native modules, 464 tests and the same 288 read-only Toad +dependency modules, zero omissions. Screen's foreign invocation/context were +deleted; original MessagePump.on_callback is now the shared execution consumer. + +No installed, terminal-writer, Toad physical or provider run occurred. Original +writer hooks/receipts were not edited; actual matching Toad/terminal acceptance +and the original missing capture response remain unqualified. All test App tasks +returned; no process or runtime was left running. diff --git a/evidence/refresh-callback-task-owner-20261006/after.json b/evidence/refresh-callback-task-owner-20261006/after.json new file mode 100644 index 0000000000..a582662a22 --- /dev/null +++ b/evidence/refresh-callback-task-owner-20261006/after.json @@ -0,0 +1,521 @@ +{ + "coverage": [ + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "c34273558bb637f17f6f3284525c2d3c3e74b5b1", + "root": "src/textual", + "parsed": 249, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "c34273558bb637f17f6f3284525c2d3c3e74b5b1", + "root": "tests", + "parsed": 464, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "2f2ffc2098a3f9456b6ac6e8948990db43296429", + "root": "src/toad", + "parsed": 288, + "omissions": [] + } + ], + "limits": "Lexical declaration and consumer coordinates; external/dynamic callback targets unresolved. Toad read-only dependency snapshot, no edits.", + "sites": { + "src/textual:src/textual/_animator.py::call_later::Attribute::Load": [ + 432, + 459, + 470 + ], + "src/textual:src/textual/_callback.py::call_later::Attribute::Load": [ + 89 + ], + "src/textual:src/textual/app.py::_on_idle::FunctionDef::declaration": [ + 5025 + ], + "src/textual:src/textual/app.py::_process_messages::Attribute::Load": [ + 2201, + 2296 + ], + "src/textual:src/textual/app.py::_process_messages_body::AsyncFunctionDef::declaration": [ + 3384 + ], + "src/textual:src/textual/app.py::call_after_refresh::Attribute::Load": [ + 3230 + ], + "src/textual:src/textual/app.py::call_later::Attribute::Load": [ + 3158, + 4615 + ], + "src/textual:src/textual/command.py::call_later::Attribute::Load": [ + 1245 + ], + "src/textual:src/textual/css/styles.py::call_later::Attribute::Load": [ + 570 + ], + "src/textual:src/textual/demo/game.py::call_after_refresh::Attribute::Load": [ + 385 + ], + "src/textual:src/textual/dom.py::call_later::Attribute::Load": [ + 410, + 452 + ], + "src/textual:src/textual/drivers/linux_driver.py::call_later::Attribute::Load": [ + 420 + ], + "src/textual:src/textual/drivers/linux_inline_driver.py::call_later::Attribute::Load": [ + 116 + ], + "src/textual:src/textual/drivers/web_driver.py::call_later::Attribute::Load": [ + 173 + ], + "src/textual:src/textual/lazy.py::call_after_refresh::Attribute::Load": [ + 63 + ], + "src/textual:src/textual/message_pump.py::_after_refresh_pending::FunctionDef::declaration": [ + 487 + ], + "src/textual:src/textual/message_pump.py::_invoke_later::Attribute::Load": [ + 570 + ], + "src/textual:src/textual/message_pump.py::_on_invoke_later::FunctionDef::declaration": [ + 565 + ], + "src/textual:src/textual/message_pump.py::_process_messages::AsyncFunctionDef::declaration": [ + 612 + ], + "src/textual:src/textual/message_pump.py::_process_messages::Attribute::Load": [ + 602 + ], + "src/textual:src/textual/message_pump.py::_process_messages_body::AsyncFunctionDef::declaration": [ + 627 + ], + "src/textual:src/textual/message_pump.py::_process_messages_body::Attribute::Load": [ + 617 + ], + "src/textual:src/textual/message_pump.py::call_after_refresh::Attribute::Load": [ + 530 + ], + "src/textual:src/textual/message_pump.py::call_after_refresh::FunctionDef::declaration": [ + 491 + ], + "src/textual:src/textual/message_pump.py::call_later::FunctionDef::declaration": [ + 534 + ], + "src/textual:src/textual/message_pump.py::on_callback::AsyncFunctionDef::declaration": [ + 991 + ], + "src/textual:src/textual/message_pump.py::wait_for_refresh::AsyncFunctionDef::declaration": [ + 513 + ], + "src/textual:src/textual/pilot.py::_on_timer_update::Attribute::Load": [ + 547, + 552, + 560 + ], + "src/textual:src/textual/pilot.py::call_later::Attribute::Load": [ + 504 + ], + "src/textual:src/textual/screen.py::_after_refresh_pending::Attribute::Load": [ + 1223, + 1350 + ], + "src/textual:src/textual/screen.py::_after_refresh_pending::FunctionDef::declaration": [ + 1221 + ], + "src/textual:src/textual/screen.py::_callbacks::Attribute::Load": [ + 1316, + 1340, + 1346, + 1347, + 1349, + 1353, + 1364, + 1387 + ], + "src/textual:src/textual/screen.py::_callbacks::Attribute::Store": [ + 294 + ], + "src/textual:src/textual/screen.py::_invoke_and_clear_callbacks::AsyncFunctionDef::declaration": [ + 1338 + ], + "src/textual:src/textual/screen.py::_invoke_and_clear_callbacks::Attribute::Load": [ + 1203, + 1317 + ], + "src/textual:src/textual/screen.py::_invoke_later::FunctionDef::declaration": [ + 1356 + ], + "src/textual:src/textual/screen.py::_on_frame_published::Attribute::Load": [ + 1265 + ], + "src/textual:src/textual/screen.py::_on_frame_published::FunctionDef::declaration": [ + 1213 + ], + "src/textual:src/textual/screen.py::_on_idle::AsyncFunctionDef::declaration": [ + 1188 + ], + "src/textual:src/textual/screen.py::_on_timer_update::Attribute::Load": [ + 355 + ], + "src/textual:src/textual/screen.py::_on_timer_update::FunctionDef::declaration": [ + 1288 + ], + "src/textual:src/textual/screen.py::_prepare_visible_screens::Attribute::Load": [ + 1193, + 1233, + 1344 + ], + "src/textual:src/textual/screen.py::_prepare_visible_screens::FunctionDef::declaration": [ + 1216 + ], + "src/textual:src/textual/screen.py::_sender_refresh_pending::FunctionDef::declaration": [ + 1319 + ], + "src/textual:src/textual/screen.py::call_after_refresh::Attribute::Load": [ + 973, + 1154 + ], + "src/textual:src/textual/screen.py::call_later::Attribute::Load": [ + 1149, + 1354, + 1645, + 1647 + ], + "src/textual:src/textual/widget.py::_after_refresh_pending::FunctionDef::declaration": [ + 1406 + ], + "src/textual:src/textual/widget.py::_callbacks::Attribute::Load": [ + 1806, + 1808, + 1811 + ], + "src/textual:src/textual/widget.py::_on_idle::AsyncFunctionDef::declaration": [ + 4812 + ], + "src/textual:src/textual/widget.py::_sender_refresh_pending::Attribute::Load": [ + 1408 + ], + "src/textual:src/textual/widget.py::call_after_refresh::Attribute::Load": [ + 3040, + 3129, + 3294, + 3782, + 3878, + 3934, + 3982 + ], + "src/textual:src/textual/widget.py::call_later::Attribute::Load": [ + 1146, + 1590, + 4833, + 4868 + ], + "src/textual:src/textual/widgets/_button.py::call_later::Attribute::Load": [ + 437 + ], + "src/textual:src/textual/widgets/_collapsible.py::call_after_refresh::Attribute::Load": [ + 222 + ], + "src/textual:src/textual/widgets/_data_table.py::_on_idle::AsyncFunctionDef::declaration": [ + 1870 + ], + "src/textual:src/textual/widgets/_data_table.py::call_after_refresh::Attribute::Load": [ + 1210, + 1255 + ], + "src/textual:src/textual/widgets/_footer.py::call_after_refresh::Attribute::Load": [ + 312 + ], + "src/textual:src/textual/widgets/_key_panel.py::call_after_refresh::Attribute::Load": [ + 176 + ], + "src/textual:src/textual/widgets/_list_view.py::call_after_refresh::Attribute::Load": [ + 215 + ], + "src/textual:src/textual/widgets/_radio_set.py::call_after_refresh::Attribute::Load": [ + 315 + ], + "src/textual:src/textual/widgets/_tabs.py::call_after_refresh::Attribute::Load": [ + 684, + 687, + 848, + 873 + ], + "src/textual:src/textual/widgets/_text_area.py::call_after_refresh::Attribute::Load": [ + 1261, + 1271 + ], + "src/textual:src/textual/widgets/_toast.py::call_later::Attribute::Load": [ + 206 + ], + "src/textual:src/textual/widgets/_tree.py::_on_idle::AsyncFunctionDef::declaration": [ + 1248 + ], + "src/textual:src/textual/widgets/_tree.py::call_after_refresh::Attribute::Load": [ + 1602 + ], + "src/textual:src/textual/widgets/_tree.py::call_later::Attribute::Load": [ + 358 + ], + "src/toad:src/toad/agent_surface.py::call_later::Attribute::Load": [ + 130 + ], + "src/toad:src/toad/application_lifetime.py::call_later::Attribute::Load": [ + 64, + 95 + ], + "src/toad:src/toad/conversation_actions.py::call_after_refresh::Attribute::Load": [ + 69, + 86 + ], + "src/toad:src/toad/conversation_submission.py::call_later::Attribute::Load": [ + 205 + ], + "src/toad:src/toad/frame_presentation.py::call_after_refresh::Attribute::Load": [ + 73, + 191, + 214 + ], + "src/toad:src/toad/live_output.py::call_later::Attribute::Load": [ + 175 + ], + "src/toad:src/toad/mounted_message_history.py::call_after_refresh::Attribute::Load": [ + 464 + ], + "src/toad:src/toad/screens/comms.py::call_after_refresh::Attribute::Load": [ + 241 + ], + "src/toad:src/toad/screens/main.py::call_after_refresh::Attribute::Load": [ + 229, + 231 + ], + "src/toad:src/toad/screens/settings.py::call_after_refresh::Attribute::Load": [ + 141 + ], + "src/toad:src/toad/screens/store.py::call_later::Attribute::Load": [ + 560 + ], + "src/toad:src/toad/screens/workspace.py::_on_frame_published::FunctionDef::declaration": [ + 163 + ], + "src/toad:src/toad/session_presentation.py::call_after_refresh::Attribute::Load": [ + 109 + ], + "src/toad:src/toad/sidebar_navigation.py::call_after_refresh::Attribute::Load": [ + 73, + 85 + ], + "src/toad:src/toad/sidebar_observation.py::call_after_refresh::Attribute::Load": [ + 86, + 226 + ], + "src/toad:src/toad/sidebar_projection.py::call_after_refresh::Attribute::Load": [ + 89 + ], + "src/toad:src/toad/tool_output.py::call_after_refresh::Attribute::Load": [ + 402 + ], + "src/toad:src/toad/transcript_filter.py::call_after_refresh::Attribute::Load": [ + 227 + ], + "src/toad:src/toad/transcript_filter.py::call_later::Attribute::Load": [ + 238, + 245 + ], + "src/toad:src/toad/transcript_publication.py::call_after_refresh::Attribute::Load": [ + 563, + 620 + ], + "src/toad:src/toad/transcript_publication.py::call_later::Attribute::Load": [ + 136 + ], + "src/toad:src/toad/transcript_source_preparation.py::call_after_refresh::Attribute::Load": [ + 114, + 140 + ], + "src/toad:src/toad/widgets/command_pane.py::wait_for_refresh::Attribute::Load": [ + 39 + ], + "src/toad:src/toad/widgets/committed_presentation.py::call_after_refresh::Attribute::Load": [ + 265 + ], + "src/toad:src/toad/widgets/comms_chat.py::call_after_refresh::Attribute::Load": [ + 183, + 324, + 337, + 370 + ], + "src/toad:src/toad/widgets/context_explorer.py::call_after_refresh::Attribute::Load": [ + 171, + 252, + 264 + ], + "src/toad:src/toad/widgets/conversation.py::call_after_refresh::Attribute::Load": [ + 569, + 697, + 1269, + 1270, + 1665, + 1678, + 1890, + 2153 + ], + "src/toad:src/toad/widgets/conversation.py::call_later::Attribute::Load": [ + 990, + 991, + 1809, + 1810, + 1953 + ], + "src/toad:src/toad/widgets/goal_bar.py::call_after_refresh::Attribute::Load": [ + 201, + 205 + ], + "src/toad:src/toad/widgets/goal_bar.py::call_later::Attribute::Load": [ + 119 + ], + "src/toad:src/toad/widgets/mandelbrot.py::call_after_refresh::Attribute::Load": [ + 161 + ], + "src/toad:src/toad/widgets/path_search.py::call_after_refresh::Attribute::Load": [ + 459 + ], + "src/toad:src/toad/widgets/project_directory_tree.py::call_after_refresh::Attribute::Load": [ + 105 + ], + "src/toad:src/toad/widgets/project_panel.py::call_after_refresh::Attribute::Load": [ + 72 + ], + "src/toad:src/toad/widgets/project_tree_intent.py::call_after_refresh::Attribute::Load": [ + 38, + 70 + ], + "src/toad:src/toad/widgets/prompt.py::call_after_refresh::Attribute::Load": [ + 606 + ], + "src/toad:src/toad/widgets/prompt.py::call_later::Attribute::Load": [ + 127 + ], + "src/toad:src/toad/widgets/prompt_popup.py::call_later::Attribute::Load": [ + 74 + ], + "src/toad:src/toad/widgets/recovery_view.py::call_after_refresh::Attribute::Load": [ + 108 + ], + "src/toad:src/toad/widgets/session_tabs.py::call_after_refresh::Attribute::Load": [ + 190, + 335 + ], + "src/toad:src/toad/widgets/session_tabs.py::call_later::Attribute::Load": [ + 188, + 196 + ], + "src/toad:src/toad/widgets/session_thread_sidebar.py::call_after_refresh::Attribute::Load": [ + 32 + ], + "src/toad:src/toad/widgets/side_bar.py::call_after_refresh::Attribute::Load": [ + 706, + 809 + ], + "src/toad:src/toad/widgets/sidebar_tree.py::call_later::Attribute::Load": [ + 67 + ], + "src/toad:src/toad/widgets/strike_text.py::call_after_refresh::Attribute::Load": [ + 35 + ], + "src/toad:src/toad/widgets/tool_call.py::call_after_refresh::Attribute::Load": [ + 386 + ], + "src/toad:src/toad/widgets/transcript_history.py::call_after_refresh::Attribute::Load": [ + 671 + ], + "src/toad:src/toad/widgets/viewport_body.py::call_after_refresh::Attribute::Load": [ + 578 + ], + "src/toad:src/toad/widgets/worker_static.py::call_after_refresh::Attribute::Load": [ + 69 + ], + "src/toad:src/toad/widgets/worker_static.py::call_later::Attribute::Load": [ + 81 + ], + "src/toad:src/toad/workspace_chrome.py::call_later::Attribute::Load": [ + 84 + ], + "src/toad:src/toad/workspace_sessions.py::call_later::Attribute::Load": [ + 173 + ], + "tests:tests/footer/test_footer.py::wait_for_refresh::Attribute::Load": [ + 56 + ], + "tests:tests/layouts/test_grid.py::wait_for_refresh::Attribute::Load": [ + 31 + ], + "tests:tests/shutdown_crash_terminal_pilot.py::call_after_refresh::Attribute::Load": [ + 19 + ], + "tests:tests/test_animator.py::call_later::Attribute::Load": [ + 271, + 292 + ], + "tests:tests/test_batch_paint_admission.py::_on_frame_published::FunctionDef::declaration": [ + 154 + ], + "tests:tests/test_batch_paint_admission.py::call_after_refresh::Attribute::Load": [ + 52, + 88, + 89, + 195, + 196, + 197, + 198 + ], + "tests:tests/test_call_x_schedulers.py::call_after_refresh::Attribute::Load": [ + 45 + ], + "tests:tests/test_call_x_schedulers.py::call_later::Attribute::Load": [ + 23 + ], + "tests:tests/test_closed_presentation_lifetime.py::_callbacks::Attribute::Load": [ + 57 + ], + "tests:tests/test_frame_transaction.py::_on_timer_update::Attribute::Load": [ + 16 + ], + "tests:tests/test_logger.py::call_after_refresh::Attribute::Load": [ + 33 + ], + "tests:tests/test_modal.py::wait_for_refresh::Attribute::Load": [ + 95 + ], + "tests:tests/test_real_app_pump_lifetime.py::_process_messages_body::AsyncFunctionDef::declaration": [ + 138 + ], + "tests:tests/test_refresh_callback_owner.py::_after_refresh_pending::Attribute::Load": [ + 96 + ], + "tests:tests/test_refresh_callback_owner.py::_callbacks::Attribute::Load": [ + 108 + ], + "tests:tests/test_refresh_callback_owner.py::call_after_refresh::Attribute::Load": [ + 46, + 50, + 99, + 100, + 101, + 102 + ], + "tests:tests/test_refresh_callback_owner.py::wait_for_refresh::Attribute::Load": [ + 44 + ], + "tests:tests/test_reparent.py::_callbacks::Attribute::Load": [ + 128, + 129 + ], + "tests:tests/test_reparent.py::_invoke_later::Attribute::Load": [ + 125 + ] + } +} diff --git a/evidence/refresh-callback-task-owner-20261006/before.json b/evidence/refresh-callback-task-owner-20261006/before.json new file mode 100644 index 0000000000..89cbff3462 --- /dev/null +++ b/evidence/refresh-callback-task-owner-20261006/before.json @@ -0,0 +1,502 @@ +{ + "coverage": [ + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "17817952f45f544b0aa9cff61c4f841c9a2f8318", + "root": "src/textual", + "parsed": 249, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "17817952f45f544b0aa9cff61c4f841c9a2f8318", + "root": "tests", + "parsed": 463, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "2f2ffc2098a3f9456b6ac6e8948990db43296429", + "root": "src/toad", + "parsed": 288, + "omissions": [] + } + ], + "limits": "Lexical declaration and consumer coordinates; external/dynamic callback targets unresolved. Toad read-only dependency snapshot, no edits.", + "sites": { + "src/textual:src/textual/_animator.py::call_later::Attribute::Load": [ + 432, + 459, + 470 + ], + "src/textual:src/textual/_callback.py::call_later::Attribute::Load": [ + 89 + ], + "src/textual:src/textual/app.py::_on_idle::FunctionDef::declaration": [ + 5025 + ], + "src/textual:src/textual/app.py::_process_messages::Attribute::Load": [ + 2201, + 2296 + ], + "src/textual:src/textual/app.py::_process_messages_body::AsyncFunctionDef::declaration": [ + 3384 + ], + "src/textual:src/textual/app.py::call_after_refresh::Attribute::Load": [ + 3230 + ], + "src/textual:src/textual/app.py::call_later::Attribute::Load": [ + 3158, + 4615 + ], + "src/textual:src/textual/command.py::call_later::Attribute::Load": [ + 1245 + ], + "src/textual:src/textual/css/styles.py::call_later::Attribute::Load": [ + 570 + ], + "src/textual:src/textual/demo/game.py::call_after_refresh::Attribute::Load": [ + 385 + ], + "src/textual:src/textual/dom.py::call_later::Attribute::Load": [ + 410, + 452 + ], + "src/textual:src/textual/drivers/linux_driver.py::call_later::Attribute::Load": [ + 420 + ], + "src/textual:src/textual/drivers/linux_inline_driver.py::call_later::Attribute::Load": [ + 116 + ], + "src/textual:src/textual/drivers/web_driver.py::call_later::Attribute::Load": [ + 173 + ], + "src/textual:src/textual/lazy.py::call_after_refresh::Attribute::Load": [ + 63 + ], + "src/textual:src/textual/message_pump.py::_after_refresh_pending::FunctionDef::declaration": [ + 487 + ], + "src/textual:src/textual/message_pump.py::_invoke_later::Attribute::Load": [ + 568 + ], + "src/textual:src/textual/message_pump.py::_on_invoke_later::FunctionDef::declaration": [ + 563 + ], + "src/textual:src/textual/message_pump.py::_process_messages::AsyncFunctionDef::declaration": [ + 610 + ], + "src/textual:src/textual/message_pump.py::_process_messages::Attribute::Load": [ + 600 + ], + "src/textual:src/textual/message_pump.py::_process_messages_body::AsyncFunctionDef::declaration": [ + 625 + ], + "src/textual:src/textual/message_pump.py::_process_messages_body::Attribute::Load": [ + 615 + ], + "src/textual:src/textual/message_pump.py::call_after_refresh::Attribute::Load": [ + 528 + ], + "src/textual:src/textual/message_pump.py::call_after_refresh::FunctionDef::declaration": [ + 491 + ], + "src/textual:src/textual/message_pump.py::call_later::FunctionDef::declaration": [ + 532 + ], + "src/textual:src/textual/message_pump.py::on_callback::AsyncFunctionDef::declaration": [ + 989 + ], + "src/textual:src/textual/message_pump.py::wait_for_refresh::AsyncFunctionDef::declaration": [ + 511 + ], + "src/textual:src/textual/pilot.py::_on_timer_update::Attribute::Load": [ + 547, + 552, + 560 + ], + "src/textual:src/textual/pilot.py::call_later::Attribute::Load": [ + 504 + ], + "src/textual:src/textual/screen.py::_after_refresh_pending::Attribute::Load": [ + 1223, + 1346 + ], + "src/textual:src/textual/screen.py::_after_refresh_pending::FunctionDef::declaration": [ + 1221 + ], + "src/textual:src/textual/screen.py::_callbacks::Attribute::Load": [ + 1316, + 1341, + 1342, + 1344, + 1349, + 1361, + 1384 + ], + "src/textual:src/textual/screen.py::_callbacks::Attribute::Store": [ + 294 + ], + "src/textual:src/textual/screen.py::_invoke_and_clear_callbacks::AsyncFunctionDef::declaration": [ + 1338 + ], + "src/textual:src/textual/screen.py::_invoke_and_clear_callbacks::Attribute::Load": [ + 1203, + 1317 + ], + "src/textual:src/textual/screen.py::_invoke_later::FunctionDef::declaration": [ + 1353 + ], + "src/textual:src/textual/screen.py::_on_frame_published::Attribute::Load": [ + 1265 + ], + "src/textual:src/textual/screen.py::_on_frame_published::FunctionDef::declaration": [ + 1213 + ], + "src/textual:src/textual/screen.py::_on_idle::AsyncFunctionDef::declaration": [ + 1188 + ], + "src/textual:src/textual/screen.py::_on_timer_update::Attribute::Load": [ + 355 + ], + "src/textual:src/textual/screen.py::_on_timer_update::FunctionDef::declaration": [ + 1288 + ], + "src/textual:src/textual/screen.py::_prepare_visible_screens::Attribute::Load": [ + 1193, + 1233, + 1345 + ], + "src/textual:src/textual/screen.py::_prepare_visible_screens::FunctionDef::declaration": [ + 1216 + ], + "src/textual:src/textual/screen.py::_sender_refresh_pending::FunctionDef::declaration": [ + 1319 + ], + "src/textual:src/textual/screen.py::call_after_refresh::Attribute::Load": [ + 973, + 1154 + ], + "src/textual:src/textual/screen.py::call_later::Attribute::Load": [ + 1149, + 1642, + 1644 + ], + "src/textual:src/textual/widget.py::_after_refresh_pending::FunctionDef::declaration": [ + 1406 + ], + "src/textual:src/textual/widget.py::_callbacks::Attribute::Load": [ + 1806, + 1808, + 1811 + ], + "src/textual:src/textual/widget.py::_on_idle::AsyncFunctionDef::declaration": [ + 4812 + ], + "src/textual:src/textual/widget.py::_sender_refresh_pending::Attribute::Load": [ + 1408 + ], + "src/textual:src/textual/widget.py::call_after_refresh::Attribute::Load": [ + 3040, + 3129, + 3294, + 3782, + 3878, + 3934, + 3982 + ], + "src/textual:src/textual/widget.py::call_later::Attribute::Load": [ + 1146, + 1590, + 4833, + 4868 + ], + "src/textual:src/textual/widgets/_button.py::call_later::Attribute::Load": [ + 437 + ], + "src/textual:src/textual/widgets/_collapsible.py::call_after_refresh::Attribute::Load": [ + 222 + ], + "src/textual:src/textual/widgets/_data_table.py::_on_idle::AsyncFunctionDef::declaration": [ + 1870 + ], + "src/textual:src/textual/widgets/_data_table.py::call_after_refresh::Attribute::Load": [ + 1210, + 1255 + ], + "src/textual:src/textual/widgets/_footer.py::call_after_refresh::Attribute::Load": [ + 312 + ], + "src/textual:src/textual/widgets/_key_panel.py::call_after_refresh::Attribute::Load": [ + 176 + ], + "src/textual:src/textual/widgets/_list_view.py::call_after_refresh::Attribute::Load": [ + 215 + ], + "src/textual:src/textual/widgets/_radio_set.py::call_after_refresh::Attribute::Load": [ + 315 + ], + "src/textual:src/textual/widgets/_tabs.py::call_after_refresh::Attribute::Load": [ + 684, + 687, + 848, + 873 + ], + "src/textual:src/textual/widgets/_text_area.py::call_after_refresh::Attribute::Load": [ + 1261, + 1271 + ], + "src/textual:src/textual/widgets/_toast.py::call_later::Attribute::Load": [ + 206 + ], + "src/textual:src/textual/widgets/_tree.py::_on_idle::AsyncFunctionDef::declaration": [ + 1248 + ], + "src/textual:src/textual/widgets/_tree.py::call_after_refresh::Attribute::Load": [ + 1602 + ], + "src/textual:src/textual/widgets/_tree.py::call_later::Attribute::Load": [ + 358 + ], + "src/toad:src/toad/agent_surface.py::call_later::Attribute::Load": [ + 130 + ], + "src/toad:src/toad/application_lifetime.py::call_later::Attribute::Load": [ + 64, + 95 + ], + "src/toad:src/toad/conversation_actions.py::call_after_refresh::Attribute::Load": [ + 69, + 86 + ], + "src/toad:src/toad/conversation_submission.py::call_later::Attribute::Load": [ + 205 + ], + "src/toad:src/toad/frame_presentation.py::call_after_refresh::Attribute::Load": [ + 73, + 191, + 214 + ], + "src/toad:src/toad/live_output.py::call_later::Attribute::Load": [ + 175 + ], + "src/toad:src/toad/mounted_message_history.py::call_after_refresh::Attribute::Load": [ + 464 + ], + "src/toad:src/toad/screens/comms.py::call_after_refresh::Attribute::Load": [ + 241 + ], + "src/toad:src/toad/screens/main.py::call_after_refresh::Attribute::Load": [ + 229, + 231 + ], + "src/toad:src/toad/screens/settings.py::call_after_refresh::Attribute::Load": [ + 141 + ], + "src/toad:src/toad/screens/store.py::call_later::Attribute::Load": [ + 560 + ], + "src/toad:src/toad/screens/workspace.py::_on_frame_published::FunctionDef::declaration": [ + 163 + ], + "src/toad:src/toad/session_presentation.py::call_after_refresh::Attribute::Load": [ + 109 + ], + "src/toad:src/toad/sidebar_navigation.py::call_after_refresh::Attribute::Load": [ + 73, + 85 + ], + "src/toad:src/toad/sidebar_observation.py::call_after_refresh::Attribute::Load": [ + 86, + 226 + ], + "src/toad:src/toad/sidebar_projection.py::call_after_refresh::Attribute::Load": [ + 89 + ], + "src/toad:src/toad/tool_output.py::call_after_refresh::Attribute::Load": [ + 402 + ], + "src/toad:src/toad/transcript_filter.py::call_after_refresh::Attribute::Load": [ + 227 + ], + "src/toad:src/toad/transcript_filter.py::call_later::Attribute::Load": [ + 238, + 245 + ], + "src/toad:src/toad/transcript_publication.py::call_after_refresh::Attribute::Load": [ + 563, + 620 + ], + "src/toad:src/toad/transcript_publication.py::call_later::Attribute::Load": [ + 136 + ], + "src/toad:src/toad/transcript_source_preparation.py::call_after_refresh::Attribute::Load": [ + 114, + 140 + ], + "src/toad:src/toad/widgets/command_pane.py::wait_for_refresh::Attribute::Load": [ + 39 + ], + "src/toad:src/toad/widgets/committed_presentation.py::call_after_refresh::Attribute::Load": [ + 265 + ], + "src/toad:src/toad/widgets/comms_chat.py::call_after_refresh::Attribute::Load": [ + 183, + 324, + 337, + 370 + ], + "src/toad:src/toad/widgets/context_explorer.py::call_after_refresh::Attribute::Load": [ + 171, + 252, + 264 + ], + "src/toad:src/toad/widgets/conversation.py::call_after_refresh::Attribute::Load": [ + 569, + 697, + 1269, + 1270, + 1665, + 1678, + 1890, + 2153 + ], + "src/toad:src/toad/widgets/conversation.py::call_later::Attribute::Load": [ + 990, + 991, + 1809, + 1810, + 1953 + ], + "src/toad:src/toad/widgets/goal_bar.py::call_after_refresh::Attribute::Load": [ + 201, + 205 + ], + "src/toad:src/toad/widgets/goal_bar.py::call_later::Attribute::Load": [ + 119 + ], + "src/toad:src/toad/widgets/mandelbrot.py::call_after_refresh::Attribute::Load": [ + 161 + ], + "src/toad:src/toad/widgets/path_search.py::call_after_refresh::Attribute::Load": [ + 459 + ], + "src/toad:src/toad/widgets/project_directory_tree.py::call_after_refresh::Attribute::Load": [ + 105 + ], + "src/toad:src/toad/widgets/project_panel.py::call_after_refresh::Attribute::Load": [ + 72 + ], + "src/toad:src/toad/widgets/project_tree_intent.py::call_after_refresh::Attribute::Load": [ + 38, + 70 + ], + "src/toad:src/toad/widgets/prompt.py::call_after_refresh::Attribute::Load": [ + 606 + ], + "src/toad:src/toad/widgets/prompt.py::call_later::Attribute::Load": [ + 127 + ], + "src/toad:src/toad/widgets/prompt_popup.py::call_later::Attribute::Load": [ + 74 + ], + "src/toad:src/toad/widgets/recovery_view.py::call_after_refresh::Attribute::Load": [ + 108 + ], + "src/toad:src/toad/widgets/session_tabs.py::call_after_refresh::Attribute::Load": [ + 190, + 335 + ], + "src/toad:src/toad/widgets/session_tabs.py::call_later::Attribute::Load": [ + 188, + 196 + ], + "src/toad:src/toad/widgets/session_thread_sidebar.py::call_after_refresh::Attribute::Load": [ + 32 + ], + "src/toad:src/toad/widgets/side_bar.py::call_after_refresh::Attribute::Load": [ + 706, + 809 + ], + "src/toad:src/toad/widgets/sidebar_tree.py::call_later::Attribute::Load": [ + 67 + ], + "src/toad:src/toad/widgets/strike_text.py::call_after_refresh::Attribute::Load": [ + 35 + ], + "src/toad:src/toad/widgets/tool_call.py::call_after_refresh::Attribute::Load": [ + 386 + ], + "src/toad:src/toad/widgets/transcript_history.py::call_after_refresh::Attribute::Load": [ + 671 + ], + "src/toad:src/toad/widgets/viewport_body.py::call_after_refresh::Attribute::Load": [ + 578 + ], + "src/toad:src/toad/widgets/worker_static.py::call_after_refresh::Attribute::Load": [ + 69 + ], + "src/toad:src/toad/widgets/worker_static.py::call_later::Attribute::Load": [ + 81 + ], + "src/toad:src/toad/workspace_chrome.py::call_later::Attribute::Load": [ + 84 + ], + "src/toad:src/toad/workspace_sessions.py::call_later::Attribute::Load": [ + 173 + ], + "tests:tests/footer/test_footer.py::wait_for_refresh::Attribute::Load": [ + 56 + ], + "tests:tests/layouts/test_grid.py::wait_for_refresh::Attribute::Load": [ + 31 + ], + "tests:tests/shutdown_crash_terminal_pilot.py::call_after_refresh::Attribute::Load": [ + 19 + ], + "tests:tests/test_animator.py::call_later::Attribute::Load": [ + 271, + 292 + ], + "tests:tests/test_batch_paint_admission.py::_on_frame_published::FunctionDef::declaration": [ + 154 + ], + "tests:tests/test_batch_paint_admission.py::call_after_refresh::Attribute::Load": [ + 52, + 88, + 89, + 195, + 196, + 197, + 198 + ], + "tests:tests/test_call_x_schedulers.py::call_after_refresh::Attribute::Load": [ + 45 + ], + "tests:tests/test_call_x_schedulers.py::call_later::Attribute::Load": [ + 23 + ], + "tests:tests/test_closed_presentation_lifetime.py::_callbacks::Attribute::Load": [ + 57 + ], + "tests:tests/test_frame_transaction.py::_on_timer_update::Attribute::Load": [ + 16 + ], + "tests:tests/test_logger.py::call_after_refresh::Attribute::Load": [ + 33 + ], + "tests:tests/test_modal.py::wait_for_refresh::Attribute::Load": [ + 95 + ], + "tests:tests/test_real_app_pump_lifetime.py::_process_messages_body::AsyncFunctionDef::declaration": [ + 138 + ], + "tests:tests/test_reparent.py::_callbacks::Attribute::Load": [ + 128, + 129 + ], + "tests:tests/test_reparent.py::_invoke_later::Attribute::Load": [ + 125 + ] + } +} diff --git a/evidence/refresh-callback-task-owner-20261006/changed-path.log b/evidence/refresh-callback-task-owner-20261006/changed-path.log new file mode 100644 index 0000000000..6ee8d69212 --- /dev/null +++ b/evidence/refresh-callback-task-owner-20261006/changed-path.log @@ -0,0 +1,2 @@ +..... [100%] +5 passed in 0.79s diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 3f95116f78..1e3759101a 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -492,7 +492,9 @@ def call_after_refresh(self, callback: Callback, *args: Any, **kwargs: Any) -> b """Schedule a callback after this owner's messages and publication complete. Widget callbacks await their subtree; Screen and App callbacks await the - complete frame. Positional and keyword arguments are passed to the callable. + complete frame. After admission, this pump's existing callback handler + executes the callable, including async work, on its own task. Positional + and keyword arguments are passed to the callable. Args: callback: A callable. diff --git a/src/textual/screen.py b/src/textual/screen.py index 1efd7c377e..e36685e367 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1336,19 +1336,22 @@ def _sender_refresh_pending(self, sender: Widget, roots: tuple[Widget, ...]) -> return False async def _invoke_and_clear_callbacks(self) -> None: - """Keep held senders in the original queue; release independent senders.""" + """Admit painted senders back to their own original message pumps.""" + if self.app._batch_count or not self._callbacks: + return + # No callback runs during this synchronous admission. Borrow one scene + # cohort, then let each sender's existing callback handler own execution. + roots = tuple(root for _, roots in self._prepare_visible_screens() for root in roots) if self.is_current else () index = 0 for _ in range(len(self._callbacks)): if self.app._batch_count or index >= len(self._callbacks): return callback, sender = self._callbacks[index] - roots = tuple(root for _, roots in self._prepare_visible_screens() for root in roots) if self.is_current else () if self.is_current and sender._after_refresh_pending(self, roots): index += 1 continue self._callbacks.pop(index) - with sender._context(): - await invoke(callback) + sender.call_later(callback) def _invoke_later(self, callback: CallbackType, sender: MessagePump) -> None: """Enqueue a callback to be invoked after the screen is repainted. diff --git a/tests/test_refresh_callback_owner.py b/tests/test_refresh_callback_owner.py new file mode 100644 index 0000000000..74c3f1e803 --- /dev/null +++ b/tests/test_refresh_callback_owner.py @@ -0,0 +1,123 @@ +import asyncio + +import pytest + +from textual._context import active_message_pump +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Static + + +@pytest.mark.parametrize("sender_kind", ["app", "widget"]) +async def test_async_sender_callback_does_not_borrow_screen_task(sender_kind): + """A suspended sender cannot own another sender's paint or callbacks.""" + complete = asyncio.Event() + sidebar_written = asyncio.Event() + child_tasks = [] + + class CallbackApp(App): + def compose(self) -> ComposeResult: + yield Static("saved history", id="history") + yield Static("pending sidebar", id="sidebar") + + app = CallbackApp() + + async def drive(pilot): + await pilot.pause() + history = app.query_one("#history") + sidebar = app.query_one("#sidebar", Static) + sender = app if sender_kind == "app" else history + child_tasks.extend((history.task, sidebar.task, app.screen.task)) + + def sidebar_published(): + assert asyncio.current_task() is sidebar.task + assert active_message_pump.get() is sidebar + assert not app.screen._compositor.pending_for(sidebar) + text = "\n".join(strip.text for strip in app.screen._compositor.render_strips()) + assert "unrelated sidebar paint" in text + sidebar_written.set() + + async def after_paint(): + assert asyncio.current_task() is sender.task + assert active_message_pump.get() is sender + # This original self-wait rule must apply to the actual pump task. + assert await sender.wait_for_refresh() is False + sidebar.update("unrelated sidebar paint") + sidebar.call_after_refresh(sidebar_published) + await sidebar_written.wait() + complete.set() + + sender.call_after_refresh(after_paint) + await asyncio.wait_for(complete.wait(), 5) + app.exit() + + await asyncio.wait_for( + app.run_async(headless=True, size=(40, 8), auto_pilot=drive), 10 + ) + assert app._exception is None + assert app._task is None + assert all(task.done() for task in child_tasks) + + +async def test_sender_delivery_preserves_held_subtree_and_whole_frame_admission(): + sidebar_written = asyncio.Event() + held_written = asyncio.Event() + screen_written = asyncio.Event() + app_written = asyncio.Event() + + class SourceScreen(Screen): + held = False + + def compose(self) -> ComposeResult: + yield Static("old source", id="source") + yield Static("old sidebar", id="sidebar") + + def _prepare_compositor_refresh(self): + return (self.query_one("#source"),) if self.held else () + + class SourceApp(App): + def get_default_screen(self): + return SourceScreen() + + app = SourceApp() + + async def drive(pilot): + await pilot.pause() + screen = app.screen + source = screen.query_one("#source", Static) + sidebar = screen.query_one("#sidebar", Static) + screen.held = True + source.update("new source") + sidebar.update("new sidebar") + + def published(sender, written): + assert asyncio.current_task() is sender.task + assert active_message_pump.get() is sender + assert not sender._after_refresh_pending(screen, screen._prepare_compositor_refresh()) + written.set() + + source.call_after_refresh(published, source, held_written) + sidebar.call_after_refresh(published, sidebar, sidebar_written) + screen.call_after_refresh(published, screen, screen_written) + app.call_after_refresh(published, app, app_written) + await asyncio.wait_for(sidebar_written.wait(), 5) + assert not held_written.is_set() + assert not screen_written.is_set() + assert not app_written.is_set() + assert screen._compositor.pending_for(source) + assert {sender for _, sender in screen._callbacks} >= {source, screen, app} + + screen.held = False + source.refresh() + await asyncio.wait_for( + asyncio.gather(held_written.wait(), screen_written.wait(), app_written.wait()), + 5, + ) + assert not screen._compositor.pending_for(source) + app.exit() + + await asyncio.wait_for( + app.run_async(headless=True, size=(40, 8), auto_pilot=drive), 10 + ) + assert app._exception is None + assert app._task is None