stream_command used a blocking proc.stdout.readline() that never woke when the underlying journalctl was silent, so Flask never delivered GeneratorExit on client disconnect — the worker thread and the journalctl child both leaked permanently and pinned the gunicorn thread pool. Switch to a select-based read loop with a 15s heartbeat tick (yielded as ""), and translate the tick to an SSE keepalive comment in the log route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
3.4 KiB
Python
117 lines
3.4 KiB
Python
import sys
|
|
from io import StringIO
|
|
import pytest
|
|
|
|
|
|
def test_run_command_passthrough_writes_to_sys_streams(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from l4d2web.services.host_commands import run_command
|
|
|
|
mock_stdout = StringIO()
|
|
mock_stderr = StringIO()
|
|
monkeypatch.setattr(sys, "stdout", mock_stdout)
|
|
monkeypatch.setattr(sys, "stderr", mock_stderr)
|
|
|
|
run_command(
|
|
["python3", "-c", "import sys; print('passed out'); print('passed err', file=sys.stderr)"],
|
|
passthrough=True,
|
|
)
|
|
|
|
assert mock_stdout.getvalue() == "passed out\n"
|
|
assert mock_stderr.getvalue() == "passed err\n"
|
|
|
|
|
|
def test_run_command_streams_stdout_and_stderr_callbacks() -> None:
|
|
from l4d2web.services.host_commands import run_command
|
|
|
|
stdout: list[str] = []
|
|
stderr: list[str] = []
|
|
|
|
result = run_command(
|
|
["python3", "-c", "import sys; print('ok'); print('warn', file=sys.stderr)"],
|
|
on_stdout=stdout.append,
|
|
on_stderr=stderr.append,
|
|
)
|
|
|
|
assert stdout == ["ok"]
|
|
assert stderr == ["warn"]
|
|
assert result.returncode == 0
|
|
assert result.stdout == "ok"
|
|
assert result.stderr == "warn"
|
|
|
|
|
|
def test_run_command_raises_host_error_on_nonzero_exit() -> None:
|
|
from l4d2web.services.host_commands import HostCommandError, run_command
|
|
|
|
with pytest.raises(HostCommandError) as exc_info:
|
|
run_command(["python3", "-c", "import sys; print('bad', file=sys.stderr); sys.exit(7)"])
|
|
|
|
assert exc_info.value.returncode == 7
|
|
assert exc_info.value.stderr == "bad"
|
|
|
|
|
|
def test_run_command_raises_cancelled_error_when_cancel_requested() -> None:
|
|
from l4d2web.services.host_commands import CommandCancelledError, run_command
|
|
|
|
stdout: list[str] = []
|
|
|
|
with pytest.raises(CommandCancelledError):
|
|
run_command(
|
|
["python3", "-c", "import time; print('ready', flush=True); time.sleep(5)"],
|
|
on_stdout=stdout.append,
|
|
should_cancel=lambda: bool(stdout),
|
|
cancel_poll_seconds=0.01,
|
|
cancel_terminate_timeout=0.2,
|
|
)
|
|
|
|
assert stdout == ["ready"]
|
|
|
|
|
|
def test_stream_command_yields_stdout_lines() -> None:
|
|
from l4d2web.services.host_commands import stream_command
|
|
|
|
lines = list(stream_command(["python3", "-c", "print('one'); print('two')"]))
|
|
|
|
assert lines == ["one", "two"]
|
|
|
|
|
|
def test_stream_command_emits_heartbeat_when_subprocess_silent() -> None:
|
|
import time
|
|
|
|
from l4d2web.services.host_commands import stream_command
|
|
|
|
cmd = [
|
|
"python3",
|
|
"-c",
|
|
"import time; time.sleep(0.4); print('done')",
|
|
]
|
|
|
|
started = time.monotonic()
|
|
items: list[str] = []
|
|
for item in stream_command(cmd, heartbeat_interval=0.05):
|
|
items.append(item)
|
|
if time.monotonic() - started > 2.0:
|
|
break
|
|
|
|
assert "done" in items, items
|
|
heartbeats = [i for i in items if i == ""]
|
|
assert len(heartbeats) >= 2, f"expected ≥2 heartbeat ticks during the silent 0.4s window, got items={items!r}"
|
|
|
|
|
|
def test_stream_command_close_releases_subprocess_promptly() -> None:
|
|
import time
|
|
|
|
from l4d2web.services.host_commands import stream_command
|
|
|
|
cmd = [
|
|
"python3",
|
|
"-c",
|
|
"import time;\nwhile True:\n time.sleep(60)",
|
|
]
|
|
|
|
gen = stream_command(cmd, heartbeat_interval=0.05)
|
|
assert next(gen) == ""
|
|
|
|
started = time.monotonic()
|
|
gen.close()
|
|
elapsed = time.monotonic() - started
|
|
assert elapsed < 1.0, f"gen.close() took {elapsed:.2f}s; subprocess cleanup must not block"
|