From b0c520b7097079566943022df2e80543540062f1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 23:18:36 -0400 Subject: [PATCH 1/2] Avoid repeated declaration reads in native message dispatch --- .../source.json | 38 +++++++++++++++++++ src/textual/message_pump.py | 19 ++++++---- 2 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 evidence/message-handler-lookup-20261006/source.json diff --git a/evidence/message-handler-lookup-20261006/source.json b/evidence/message-handler-lookup-20261006/source.json new file mode 100644 index 0000000000..6ba6323768 --- /dev/null +++ b/evidence/message-handler-lookup-20261006/source.json @@ -0,0 +1,38 @@ +{ + "native": { + "modules": 249, + "omissions": [], + "sites": [ + [ + "src/textual/message_pump.py", + 83, + "__new__" + ], + [ + "src/textual/message_pump.py", + 819, + "_get_dispatch_methods" + ], + [ + "src/textual/message_pump.py", + 890, + "_on_message" + ], + [ + "src/textual/message_pump.py", + 901, + "self._get_dispatch_methods" + ], + [ + "src/textual/message_pump.py", + 759, + "self._get_dispatch_methods" + ] + ] + }, + "toad": { + "modules": 288, + "omissions": [], + "sites": [] + } +} diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 28323aa749..4bf222fc45 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -825,22 +825,25 @@ def _get_dispatch_methods( method_name: Handler method name. message: Message object. """ - from textual.widget import Widget - methods_dispatched: set[Callable] = set() - message_mro = [ - _type for _type in message.__class__.__mro__ if issubclass(_type, Message) - ] + message_types = message.__class__.__mro__ + message_mro: list[type[Message]] | None = None + private_method_name = f"_{method_name}" for cls in self.__class__.__mro__: if message._no_default_action: break + declarations = cls.__dict__ # Try decorated handlers first decorated_handlers = cast( "dict[type[Message], list[tuple[Callable, dict[str, tuple[SelectorSet, ...]]]]] | None", - cls.__dict__.get("_decorated_handlers"), + declarations.get("_decorated_handlers"), ) if decorated_handlers: + if message_mro is None: + message_mro = [ + _type for _type in message_types if issubclass(_type, Message) + ] for message_class in message_mro: handlers = decorated_handlers.get(message_class, []) @@ -853,6 +856,8 @@ def _get_dispatch_methods( else: if not message._sender: continue + from textual.widget import Widget + for attribute, selector in selectors.items(): node = getattr(message, attribute) if node is None: @@ -869,7 +874,7 @@ def _get_dispatch_methods( # Fall back to the naming convention # But avoid calling the handler if it was decorated - method = cls.__dict__.get(f"_{method_name}") or cls.__dict__.get( + method = declarations.get(private_method_name) or declarations.get( method_name ) if method is not None and not getattr(method, "_textual_on", None): From 107c1d47edf90922b1958f560a0cb7e84ff0f782 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 23:21:09 -0400 Subject: [PATCH 2/2] Verify live handler replacement and preserve native dispatch semantics --- .../message-handler-lookup-20261006/README.md | 43 +++++++++++++++ .../controls.log | 2 + src/textual/message_pump.py | 4 +- tests/test_on.py | 52 +++++++++++++++++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 evidence/message-handler-lookup-20261006/README.md create mode 100644 evidence/message-handler-lookup-20261006/controls.log diff --git a/evidence/message-handler-lookup-20261006/README.md b/evidence/message-handler-lookup-20261006/README.md new file mode 100644 index 0000000000..446e1284de --- /dev/null +++ b/evidence/message-handler-lookup-20261006/README.md @@ -0,0 +1,43 @@ +# Native handler lookup + +The native metaclass already collects decorated functions and parsed selectors +at class construction. Unlike Core MroDispatch before its refactor, native +dispatch does not scan every method on every event. A new declaration registry +would duplicate that owner and could freeze legitimate method replacement. + +The remaining repeated work is inside MessagePump._get_dispatch_methods: +class.__dict__ was acquired up to three times per owner, the private handler +name was constructed per owner, and the message MRO was filtered even if no +decorated declaration used it. Now each owner uses one live mappingproxy, the +private name is prepared once, and the captured message MRO is filtered only +when decorated handlers require it. No persistent state, event cache or wrapper. + +The original full message MRO is captured before the first yield, preserving +its invocation lifetime even if classes change while a handler awaits. Class +mappingproxies remain live: replacements during decorated delivery are visible +to the following naming lookup. Decorated declaration lists, their duplicate +suppression and post-yield ordering remain original. Every invocation still +binds the actual function to its current instance and class. Selectors still +read current message attributes and match the current widget. Default-action +checks, namespace naming, private/public precedence and bubbling are unchanged. +Native inheritance deliberately calls declared handlers across C3 owners; +Core-style unannotated masking was not imposed on this different contract. + +Parent weak references and ancestor traversal remain live DOM/lifetime answers, +not class declarations. This change neither freezes them nor changes readiness, +geometry, publication, queues or pump task custody. + +23 affected checks passed in 2.36s: existing decorator, message handling and +message-pump controls plus one real App C3/private-precedence/dynamic replacement +control. That control changes the named handler while the decorated yield is +suspended, then exercises another App instance to verify fresh binding. +controls.log retains the terminal result. No provider, package, public App or +installed pin operation. No latency gain or CPU attribution claimed from the +original sampled stack-transition groups. Parent owns matching App recording. + +source.json uses the existing refactor-audit Package parser for native and Toad +declarations/lexical consumers, with no parse omissions. Dynamic external +metaclasses that fabricate dictionary views cannot be inferred from lexical +sites; the native owner uses real class mappingproxies. Arbitrary mutation of +decorated declaration lists remains the original live-list behavior. No class +method or annotation supply was frozen. diff --git a/evidence/message-handler-lookup-20261006/controls.log b/evidence/message-handler-lookup-20261006/controls.log new file mode 100644 index 0000000000..527ac27e50 --- /dev/null +++ b/evidence/message-handler-lookup-20261006/controls.log @@ -0,0 +1,2 @@ +....................... [100%] +23 passed in 2.36s diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4bf222fc45..4118680361 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -825,6 +825,8 @@ def _get_dispatch_methods( method_name: Handler method name. message: Message object. """ + from textual.widget import Widget + methods_dispatched: set[Callable] = set() message_types = message.__class__.__mro__ message_mro: list[type[Message]] | None = None @@ -856,8 +858,6 @@ def _get_dispatch_methods( else: if not message._sender: continue - from textual.widget import Widget - for attribute, selector in selectors.items(): node = getattr(message, attribute) if node is None: diff --git a/tests/test_on.py b/tests/test_on.py index 7483a7be9f..b148ef16f0 100644 --- a/tests/test_on.py +++ b/tests/test_on.py @@ -13,6 +13,58 @@ from textual.widgets import Button, TabbedContent, TabPane +async def test_live_named_replacement_during_decorated_dispatch(): + class Notice(Message, namespace="lookup", bubble=False): + pass + + class BaseApp(App): + def _on_lookup_notice(self): + self.received.append("base") + + class Left(BaseApp): + def on_lookup_notice(self): + self.received.append("left") + + class Right(BaseApp): + def on_lookup_notice(self): + self.received.append("right") + + class DispatchApp(Left, Right): + def __init__(self): + super().__init__() + self.received = [] + + def _on_lookup_notice(self): + self.received.append("old") + + def on_lookup_notice(self): + raise AssertionError("private handler has precedence") + + @on(Notice) + async def observe(self): + self.received.append("decorated") + # The generator is suspended at its decorated yield. Its captured + # class mapping must still expose real method replacement. + def replacement(instance): + instance.received.append("new") + + type(self)._on_lookup_notice = replacement + + app = DispatchApp() + async with app.run_test() as pilot: + app.post_message(Notice()) + await pilot.pause() + assert app.received == ["decorated", "new", "left", "right", "base"] + + # Binding belongs to the current instance, not a cached bound method. + second = DispatchApp() + async with second.run_test() as pilot: + second.post_message(Notice()) + await pilot.pause() + assert second.received == ["decorated", "new", "left", "right", "base"] + assert app.received == ["decorated", "new", "left", "right", "base"] + + async def test_on_button_pressed() -> None: """Test handlers with @on decorator."""