Fix stdin stalls and test warnings
This commit is contained in:
+16
-4
@@ -5,6 +5,10 @@ from click.testing import CliRunner
|
||||
from webterm import cli
|
||||
|
||||
|
||||
def _close_coroutine(coro) -> None:
|
||||
coro.close()
|
||||
|
||||
|
||||
class TestCLI:
|
||||
"""Tests for CLI command."""
|
||||
|
||||
@@ -31,7 +35,7 @@ class TestCLI:
|
||||
calls["run"] = True
|
||||
|
||||
monkeypatch.setattr(cli, "LocalServer", FakeServer)
|
||||
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None)
|
||||
monkeypatch.setattr(cli.asyncio, "run", _close_coroutine)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli.app, ["htop"])
|
||||
@@ -55,7 +59,7 @@ class TestCLI:
|
||||
|
||||
monkeypatch.setenv("SHELL", "/bin/zsh")
|
||||
monkeypatch.setattr(cli, "LocalServer", FakeServer)
|
||||
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: None)
|
||||
monkeypatch.setattr(cli.asyncio, "run", _close_coroutine)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli.app, [])
|
||||
@@ -131,7 +135,11 @@ def test_cli_docker_watch_mode(monkeypatch):
|
||||
calls["run"] = True
|
||||
|
||||
monkeypatch.setattr(cli, "LocalServer", FakeServer)
|
||||
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: calls.setdefault("run", True))
|
||||
def run_and_close(coro):
|
||||
calls.setdefault("run", True)
|
||||
coro.close()
|
||||
|
||||
monkeypatch.setattr(cli.asyncio, "run", run_and_close)
|
||||
monkeypatch.setattr(cli.constants, "DEBUG", True)
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -155,7 +163,11 @@ def test_cli_windows_branch(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(cli, "LocalServer", FakeServer)
|
||||
monkeypatch.setattr(cli.constants, "WINDOWS", True)
|
||||
monkeypatch.setattr(cli.asyncio, "run", lambda _coro: calls.setdefault("run", True))
|
||||
def run_and_close(coro):
|
||||
calls.setdefault("run", True)
|
||||
coro.close()
|
||||
|
||||
monkeypatch.setattr(cli.asyncio, "run", run_and_close)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli.app, ["--docker-watch"])
|
||||
|
||||
@@ -12,6 +12,14 @@ from webterm.local_server import (
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_asyncmock_call(mock: AsyncMock, timeout: float = 0.1) -> None:
|
||||
async def _wait() -> None:
|
||||
while mock.await_count == 0:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
await asyncio.wait_for(_wait(), timeout=timeout)
|
||||
|
||||
|
||||
class TestGetStaticPath:
|
||||
"""Tests for static path."""
|
||||
|
||||
@@ -599,6 +607,8 @@ class TestLocalServerMoreCoverage:
|
||||
ws = MagicMock()
|
||||
created = await server_with_no_apps._dispatch_ws_message(["stdin"], "rk", ws, False)
|
||||
assert created is False
|
||||
# stdin writes are fire-and-forget; wait until send_bytes is awaited
|
||||
await wait_for_asyncmock_call(session.send_bytes)
|
||||
session.send_bytes.assert_awaited_once_with(b"")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -744,3 +754,59 @@ class TestLocalServerMoreCoverage:
|
||||
|
||||
assert not queue.empty()
|
||||
assert queue.get_nowait() == "my-route"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_stdin_does_not_block_ws_loop(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
"""Stdin writes should be fire-and-forget so the WS loop keeps processing."""
|
||||
send_started = asyncio.Event()
|
||||
send_gate = asyncio.Event()
|
||||
|
||||
async def slow_send(_data):
|
||||
send_started.set()
|
||||
await send_gate.wait()
|
||||
return True
|
||||
|
||||
session = MagicMock()
|
||||
session.send_bytes = AsyncMock(side_effect=slow_send)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
|
||||
)
|
||||
|
||||
ws = MagicMock()
|
||||
# _dispatch_ws_message should return immediately even though send_bytes blocks
|
||||
created = await server_with_no_apps._dispatch_ws_message(
|
||||
["stdin", "hello"], "rk", ws, False
|
||||
)
|
||||
assert created is False
|
||||
|
||||
# The background task should have been created but not finished
|
||||
await send_started.wait()
|
||||
assert not send_gate.is_set()
|
||||
|
||||
# Unblock and let the task finish
|
||||
send_gate.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_stdin_logs_timeout(
|
||||
self, server_with_no_apps, monkeypatch, caplog
|
||||
):
|
||||
"""_write_stdin should log a warning and not raise on timeout."""
|
||||
async def hang_forever(_data):
|
||||
await asyncio.sleep(999)
|
||||
return True
|
||||
|
||||
session = MagicMock()
|
||||
session.send_bytes = AsyncMock(side_effect=hang_forever)
|
||||
|
||||
import logging
|
||||
|
||||
# Use a very short timeout for testing
|
||||
monkeypatch.setattr("webterm.local_server.STDIN_WRITE_TIMEOUT", 0.01)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="webterm"):
|
||||
await server_with_no_apps._write_stdin(session, "x", "rk")
|
||||
|
||||
assert "Stdin write timeout" in caplog.text
|
||||
|
||||
@@ -136,3 +136,36 @@ class TestPoller:
|
||||
# Queues should have None
|
||||
assert q1.get_nowait() is None
|
||||
assert q2.get_nowait() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_with_timeout_returns_true_on_success(self):
|
||||
"""write_with_timeout returns True when write completes."""
|
||||
poller = Poller()
|
||||
poller._loop = asyncio.get_event_loop()
|
||||
|
||||
with patch.object(poller._selector, "register"):
|
||||
poller.add_file(42)
|
||||
|
||||
async def instant_write(fd, data):
|
||||
# Simulate immediate completion
|
||||
pass
|
||||
|
||||
with patch.object(poller, "write", side_effect=instant_write):
|
||||
result = await poller.write_with_timeout(42, b"test", timeout=1.0)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_with_timeout_returns_false_on_timeout(self):
|
||||
"""write_with_timeout returns False when write times out."""
|
||||
poller = Poller()
|
||||
poller._loop = asyncio.get_event_loop()
|
||||
|
||||
with patch.object(poller._selector, "register"):
|
||||
poller.add_file(42)
|
||||
|
||||
async def slow_write(fd, data):
|
||||
await asyncio.sleep(999)
|
||||
|
||||
with patch.object(poller, "write", side_effect=slow_write):
|
||||
result = await poller.write_with_timeout(42, b"test", timeout=0.01)
|
||||
assert result is False
|
||||
|
||||
Reference in New Issue
Block a user