Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyflowlauncher/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions pyflowlauncher/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
62 changes: 62 additions & 0 deletions tests/integration/test_v2_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading