diff --git a/pyflowlauncher/jsonrpc.py b/pyflowlauncher/jsonrpc.py index fa8e477..cde22ba 100644 --- a/pyflowlauncher/jsonrpc.py +++ b/pyflowlauncher/jsonrpc.py @@ -75,7 +75,10 @@ async def messages(self): _logger.warning( "Discarding JSON-RPC response with unknown id: %r", req_id) elif not fut.done(): - fut.set_result(msg.get('result')) + if 'error' in msg and msg['error'] is not None: + fut.set_exception(RuntimeError(msg['error'])) + else: + fut.set_result(msg.get('result')) continue yield msg diff --git a/pyflowlauncher/launcher.py b/pyflowlauncher/launcher.py index dcbf27b..ecebb1a 100644 --- a/pyflowlauncher/launcher.py +++ b/pyflowlauncher/launcher.py @@ -216,33 +216,36 @@ async def _handle_request( # JsonRPCExecuteResponse, so the command must be sent as our own # request instead of being dropped. try: - await self._forward_to_host(result['Method'], list(result.get('Parameters', []))) + succeeded = await self._forward_to_host( + result['Method'], list(result.get('Parameters', []))) except asyncio.CancelledError: self._client.send({'id': request_id, 'result': None, 'error': { 'code': -32800, 'message': 'Request cancelled', }}) raise - self._respond(request_id, {'hide': True}) + self._respond(request_id, {'hide': succeeded}) return self._send_response(request_id, method, result) async def _handle_builtin_action(self, request_id: Any, method: str, params: list) -> None: try: - await self._forward_to_host(method, params) + succeeded = await self._forward_to_host(method, params) except asyncio.CancelledError: self._client.send({'id': request_id, 'result': None, 'error': { 'code': -32800, 'message': 'Request cancelled', }}) raise - self._respond(request_id, {'hide': True}) + self._respond(request_id, {'hide': succeeded}) - async def _forward_to_host(self, method: str, params: list) -> None: + async def _forward_to_host(self, method: str, params: list) -> bool: try: await self._client.request(_host_method(method), params) + return True except asyncio.CancelledError: raise except Exception: self.logger.exception("Failed to forward action %r to the host", method) + return False def _respond(self, request_id: Any, result: Any) -> None: """Send a response in the uniform {id, result, error} envelope.""" diff --git a/tests/integration/test_v2_protocol.py b/tests/integration/test_v2_protocol.py index c244961..6dfe5a9 100644 --- a/tests/integration/test_v2_protocol.py +++ b/tests/integration/test_v2_protocol.py @@ -498,6 +498,68 @@ async def _inner(): resp = query_response(responses, 5) assert resp.get('result', {}).get('debugMessage') != 'Internal error' + def test_builtin_action_error_reply_does_not_hide_window(self): + """Regression: a host-side JSON-RPC error forwarding a built-in action + must not be reported as {'hide': True} — the window should stay open + so the user can see something went wrong.""" + plugin = make_plugin() + + async def dispatch(method: str, params: list) -> Any: + return await plugin._event_handler.trigger_event(method, *params) + + stdin_text = ( + json.dumps({'id': 10, 'method': 'Flow.Launcher.OpenAppUri', + 'params': [['playnite://playnite/start/abc']]}) + '\n' + + json.dumps({'id': 1, 'error': {'code': -32601, 'message': 'not found'}}) + '\n' + + json.dumps({'id': 11, 'method': 'close', 'params': []}) + '\n' + ) + responses = [] + + async def _inner(): + with patch('sys.stdin', StringIO(stdin_text)), \ + patch('sys.stdout', StringIO()) as out: + await plugin._launcher.run(dispatch) + out.seek(0) + for line in out.read().splitlines(): + if line.strip(): + responses.append(json.loads(line)) + + asyncio.run(_inner()) + + original = query_response(responses, 10) + assert original['result'] == {'hide': False} + assert original.get('error') is None + + def test_builtin_action_stream_closed_does_not_hide_window(self): + """Regression: if the stream closes before the host replies to a + forwarded action, the original request must not be reported as + {'hide': True}.""" + plugin = make_plugin() + + async def dispatch(method: str, params: list) -> Any: + return await plugin._event_handler.trigger_event(method, *params) + + stdin_text = ( + json.dumps({'id': 10, 'method': 'Flow.Launcher.OpenAppUri', + 'params': [['playnite://playnite/start/abc']]}) + '\n' + ) + responses = [] + + async def _inner(): + with patch('sys.stdin', StringIO(stdin_text)), \ + patch('sys.stdout', StringIO()) as out: + await plugin._launcher.run(dispatch) + out.seek(0) + for line in out.read().splitlines(): + if line.strip(): + responses.append(json.loads(line)) + + asyncio.run(_inner()) + + original = query_response(responses, 10) + assert original['result'] == {'hide': False} + assert original.get('error') is None + class TestV2ReturnedCommands: """A registered action method that returns an api Command (issue #41). diff --git a/tests/unit/test_launcher.py b/tests/unit/test_launcher.py index f94771c..65ac224 100644 --- a/tests/unit/test_launcher.py +++ b/tests/unit/test_launcher.py @@ -325,8 +325,10 @@ async def _run(): asyncio.run(_run()) # StreamJsonRpc reads the 'result' member of the response, so 'hide' # must be nested inside it (deserialized as JsonRPCExecuteResponse). + # The host never replies to the forwarded action here, so the forward + # times out and 'hide' must be False (don't hide on a failed forward). action_resp = next(r for r in output if r.get('id') == 5) - assert action_resp == {'id': 5, 'result': {'hide': True}, 'error': None} + assert action_resp == {'id': 5, 'result': {'hide': False}, 'error': None} def test_query_response_includes_error_key(self): """All responses must carry the same {id, result, error} envelope."""