diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ea0b80e6..6da4d61d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.1.3 + +### Bug fixes + +* Fix cancelled client method calls keeping their responses in memory for the rest of the session, and `invoke_method()` timeouts on Python 3.10 skipping the "Timeout waiting for invokeMethod" message ([#7065](https://github.com/flet-dev/flet/pull/7065)) by @remote-controlled-man. + ## 1.1.2 ### Improvements diff --git a/sdk/python/packages/flet/src/flet/messaging/session.py b/sdk/python/packages/flet/src/flet/messaging/session.py index 14dfb47b5c..772e530d51 100644 --- a/sdk/python/packages/flet/src/flet/messaging/session.py +++ b/sdk/python/packages/flet/src/flet/messaging/session.py @@ -507,29 +507,36 @@ async def invoke_method( evt = asyncio.Event() self.__method_calls[call_id] = evt - # call method - self.__send_message( - Message( - MessageAction.INVOKE_METHOD, - InvokeMethodRequestBody( - control_id=control_id, call_id=call_id, name=method_name, args=args - ), + try: + # call method + self.__send_message( + Message( + MessageAction.INVOKE_METHOD, + InvokeMethodRequestBody( + control_id=control_id, + call_id=call_id, + name=method_name, + args=args, + ), + ) ) - ) - try: - await asyncio.wait_for(evt.wait(), timeout=timeout) - except TimeoutError: - if call_id in self.__method_calls: - del self.__method_calls[call_id] - raise TimeoutError( - f"Timeout waiting for invokeMethod {method_name}({args}) call" - ) from None - - result, err = self.__method_call_results.pop(evt) - if err: - raise RuntimeError(err) - return result + try: + await asyncio.wait_for(evt.wait(), timeout=timeout) + except asyncio.TimeoutError: + raise TimeoutError( + f"Timeout waiting for invokeMethod {method_name}({args}) call" + ) from None + + result, err = self.__method_call_results.pop(evt) + if err: + raise RuntimeError(err) + return result + finally: + self.__method_calls.pop(call_id, None) + # Cancellation can arrive after the response was stored but before + # the waiting coroutine resumes to consume it. + self.__method_call_results.pop(evt, None) def handle_invoke_method_results( self, control_id: int, call_id: str, result: Any, error: Optional[str] diff --git a/sdk/python/packages/flet/tests/test_session_invoke_method.py b/sdk/python/packages/flet/tests/test_session_invoke_method.py new file mode 100644 index 0000000000..36ddc16900 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_session_invoke_method.py @@ -0,0 +1,99 @@ +import asyncio +import gc +import weakref + +import pytest + +from flet.messaging.connection import Connection +from flet.messaging.session import Session +from flet.pubsub.pubsub_hub import PubSubHub + + +class _RecordingConnection(Connection): + def __init__(self): + super().__init__() + self.messages = [] + + def send_message(self, message): + self.messages.append(message) + + +class _Result(dict): + """A response whose lifetime can be observed without inspecting the session.""" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("response_before_cancel", [False, True]) +async def test_cancelled_call_does_not_retain_response(response_before_cancel): + """Cancelled calls drop their response, whether it arrives before or after.""" + conn = _RecordingConnection() + conn.pubsubhub = PubSubHub() + conn.loop = asyncio.get_running_loop() + session = Session(conn) + try: + call = asyncio.create_task(session.page.get_package_info()) + await asyncio.sleep(0) + request = conn.messages[-1].body + + result = _Result(app_name="My App", version="1.0.0") + result_ref = weakref.ref(result) + if response_before_cancel: + session.handle_invoke_method_results( + request.control_id, request.call_id, result, None + ) + + call.cancel() + with pytest.raises(asyncio.CancelledError): + await call + + if not response_before_cancel: + session.handle_invoke_method_results( + request.control_id, request.call_id, result, None + ) + del result + gc.collect() + + assert result_ref() is None + finally: + session.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("close_session", [False, True]) +async def test_failed_call_does_not_retain_late_response(close_session): + """Timed-out and session-closed calls drop a response that arrives later.""" + conn = _RecordingConnection() + conn.pubsubhub = PubSubHub() + conn.loop = asyncio.get_running_loop() + session = Session(conn) + try: + call = asyncio.create_task( + session.invoke_method( + session.page._i, + "get_package_info", + {}, + timeout=None if close_session else 0, + ) + ) + await asyncio.sleep(0) + request = conn.messages[-1].body + + if close_session: + session.close() + with pytest.raises(RuntimeError, match="Session closed"): + await call + else: + with pytest.raises(TimeoutError, match="Timeout waiting for invokeMethod"): + await call + + result = _Result(app_name="My App", version="1.0.0") + result_ref = weakref.ref(result) + session.handle_invoke_method_results( + request.control_id, request.call_id, result, None + ) + del result + gc.collect() + + assert result_ref() is None + finally: + session.close()