fix(plugins): bound async plugin command await with 30s timeout

Follow-up to #17963. The threaded branch of resolve_plugin_command_result
previously called Event.wait() with no timeout — a hung async plugin
handler would wedge the terminal indefinitely. Cap the wait at 30s and
raise TimeoutError instead. Added a regression test covering the hung
handler path.
This commit is contained in:
teknium1 2026-04-30 19:53:08 -07:00 committed by Teknium
parent ca9a61ae38
commit 447a2bba3a
2 changed files with 29 additions and 2 deletions

View File

@ -1229,13 +1229,18 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]:
return entry["handler"] if entry else None
_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0
def resolve_plugin_command_result(result: Any) -> Any:
"""Resolve a plugin command return value, awaiting async handlers when needed.
Sync CLI/TUI dispatch sites call plugin handlers from plain functions.
If a handler is async, await it directly when no loop is running; if
we're already inside an active loop, run it in a helper thread with its
own loop so the caller still gets a concrete result synchronously.
own loop so the caller still gets a concrete result synchronously. The
threaded path is bounded by a 30s timeout so a hung async handler cannot
wedge the terminal indefinitely.
"""
if not inspect.isawaitable(result):
return result
@ -1263,7 +1268,11 @@ def resolve_plugin_command_result(result: Any) -> Any:
daemon=True,
)
thread.start()
done.wait()
if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS):
raise TimeoutError(
"Plugin command async handler did not complete within "
f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s"
)
if "exc" in failure:
raise failure["exc"]
return outcome.get("value")

View File

@ -1082,6 +1082,24 @@ class TestPluginCommandResultResolution:
monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop())
assert resolve_plugin_command_result(_handler()) == "threaded-ok"
def test_running_loop_timeout_does_not_hang_forever(self, monkeypatch):
"""Threaded path must abort a hung async handler instead of blocking the caller."""
import asyncio as _asyncio
class _Loop:
pass
async def _slow_handler():
await _asyncio.sleep(10)
return "should-not-reach"
monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop())
monkeypatch.setattr("hermes_cli.plugins._PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS", 0.1)
import pytest
with pytest.raises(TimeoutError):
resolve_plugin_command_result(_slow_handler())
# ── TestPluginDispatchTool ────────────────────────────────────────────────