arcade-mcp/libs/tests/cli/test_windows_subprocess.py
jottakka bcee0f556f
Left over fixes for Windows Papercut PR (#781)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> **Low Risk**
> Mostly CI/test and CLI output tweaks, plus a small refactor to reuse
existing subprocess termination logic; low risk with minor potential for
CI environment/version compatibility issues.
> 
> **Overview**
> Expands CI coverage by adding Python `3.13` and `3.14` to the GitHub
Actions matrices (main tests, install test, and no-auth CLI
integration), and removes a redundant editable install step in the
no-auth workflow.
> 
> Cleans up Windows subprocess handling by dropping
`arcade_cli.deploy._graceful_terminate` and calling the shared
`arcade_core.subprocess_utils.graceful_terminate_process` directly, with
corresponding test updates.
> 
> Improves `arcade new` scaffolding guidance by printing numbered “Next
steps” with explicit stdio/HTTP run options, and adds/updates CLI tests
to assert this output. Also bumps package version to `1.11.2` and
tightens pre-commit `ruff` excludes (no longer excluding `_scratch`).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
55c2ae106f13e5657acdbebf63e00d74c171181f. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-26 13:24:15 -03:00

296 lines
12 KiB
Python

"""Tests for Windows-specific subprocess flags and signal handling.
Verifies that:
- Background subprocess calls set CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP on Windows.
- graceful_terminate_process sends CTRL_BREAK_EVENT on Windows, falls back to terminate().
- MCPApp._run_with_reload shutdown uses CTRL_BREAK_EVENT on Windows.
- stdio transport registers a stdlib signal.signal fallback on Windows.
Tests that verify Windows-specific *logic* (flag construction, signal dispatch)
keep ``sys.platform`` mocking because Popen/process objects are also fully mocked.
Tests for the non-Windows path use ``pytest.mark.skipif`` instead.
"""
from __future__ import annotations
import asyncio
import signal
import subprocess
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Shared constants/helpers keep Windows behavior tests DRY and focused.
WIN_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
WIN_CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
WIN_CTRL_BREAK_EVENT = getattr(signal, "CTRL_BREAK_EVENT", 1)
def _running_process() -> MagicMock:
proc = MagicMock()
proc.poll.return_value = None # Process is running
return proc
@contextmanager
def _patch_win32_subprocess_flags() -> Iterator[None]:
with (
patch.object(sys, "platform", "win32"),
patch.object(subprocess, "CREATE_NO_WINDOW", WIN_CREATE_NO_WINDOW, create=True),
patch.object(
subprocess,
"CREATE_NEW_PROCESS_GROUP",
WIN_CREATE_NEW_PROCESS_GROUP,
create=True,
),
):
yield
@contextmanager
def _patch_win32_ctrl_break() -> Iterator[None]:
with (
patch.object(sys, "platform", "win32"),
patch.object(signal, "CTRL_BREAK_EVENT", WIN_CTRL_BREAK_EVENT, create=True),
):
yield
# ---------------------------------------------------------------------------
# deploy.py — start_server_process()
# ---------------------------------------------------------------------------
class TestDeployCreateNoWindow:
"""Verify start_server_process sets CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP on Windows."""
@patch("arcade_cli.deploy.find_python_interpreter")
@patch("arcade_cli.deploy.subprocess.Popen")
def test_sets_flags_on_win32(
self, mock_popen: MagicMock, mock_python: MagicMock
) -> None:
mock_python.return_value = Path("python.exe")
mock_popen.return_value = _running_process()
# sys.platform mock: verifies flag-construction logic with fully-mocked Popen.
with _patch_win32_subprocess_flags():
from arcade_cli.deploy import start_server_process
start_server_process("server.py")
_, kwargs = mock_popen.call_args
flags = kwargs.get("creationflags", 0)
# Both flags must be present
assert flags & WIN_CREATE_NO_WINDOW, "CREATE_NO_WINDOW must be set"
assert flags & WIN_CREATE_NEW_PROCESS_GROUP, "CREATE_NEW_PROCESS_GROUP must be set"
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows path: creationflags must be 0")
@patch("arcade_cli.deploy.find_python_interpreter")
@patch("arcade_cli.deploy.subprocess.Popen")
def test_no_creationflags_on_non_windows(
self, mock_popen: MagicMock, mock_python: MagicMock
) -> None:
mock_python.return_value = Path("python3")
mock_popen.return_value = _running_process()
from arcade_cli.deploy import start_server_process
start_server_process("server.py")
_, kwargs = mock_popen.call_args
assert kwargs.get("creationflags") == 0
@pytest.mark.parametrize(
("debug", "expects_devnull"),
[
(False, True),
(True, False),
],
ids=["non-debug-devnull", "debug-inherits-streams"],
)
@patch("arcade_cli.deploy.find_python_interpreter")
@patch("arcade_cli.deploy.subprocess.Popen")
def test_stream_configuration_by_debug_mode(
self,
mock_popen: MagicMock,
mock_python: MagicMock,
debug: bool,
expects_devnull: bool,
) -> None:
"""Stream handling should switch between DEVNULL and inherited streams."""
mock_python.return_value = Path("python.exe")
mock_popen.return_value = _running_process()
# sys.platform mock: verifies stream-mode logic with fully-mocked Popen.
with _patch_win32_subprocess_flags():
from arcade_cli.deploy import start_server_process
start_server_process("server.py", debug=debug)
_, kwargs = mock_popen.call_args
if expects_devnull:
assert kwargs.get("stdout") == subprocess.DEVNULL
assert kwargs.get("stderr") == subprocess.DEVNULL
else:
assert kwargs.get("stdout") is None
assert kwargs.get("stderr") is None
# ---------------------------------------------------------------------------
# subprocess_utils.py — graceful_terminate_process()
# ---------------------------------------------------------------------------
class TestGracefulTerminate:
"""Verify graceful_terminate_process uses CTRL_BREAK_EVENT on Windows."""
def test_sends_ctrl_break_on_win32(self) -> None:
"""On Windows, graceful_terminate_process should send CTRL_BREAK_EVENT."""
from arcade_core.subprocess_utils import graceful_terminate_process
mock_proc = MagicMock()
# sys.platform mock: verifies CTRL_BREAK_EVENT dispatch with mocked process.
with _patch_win32_ctrl_break():
graceful_terminate_process(mock_proc)
# Should try send_signal with CTRL_BREAK_EVENT (not terminate)
mock_proc.send_signal.assert_called_once_with(WIN_CTRL_BREAK_EVENT)
mock_proc.terminate.assert_not_called()
def test_falls_back_to_terminate_on_win32_oserror(self) -> None:
"""If send_signal fails on Windows, fall back to terminate."""
from arcade_core.subprocess_utils import graceful_terminate_process
mock_proc = MagicMock()
mock_proc.send_signal.side_effect = OSError("Process exited")
# sys.platform mock: exercises OSError fallback with mocked process.
with _patch_win32_ctrl_break():
graceful_terminate_process(mock_proc)
mock_proc.terminate.assert_called_once()
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows terminate() path")
def test_calls_terminate_on_linux(self) -> None:
"""On Linux/macOS, graceful_terminate_process should call terminate() directly."""
from arcade_core.subprocess_utils import graceful_terminate_process
mock_proc = MagicMock()
graceful_terminate_process(mock_proc)
mock_proc.terminate.assert_called_once()
mock_proc.send_signal.assert_not_called()
# ---------------------------------------------------------------------------
# mcp_app.py — runtime behavior checks
# ---------------------------------------------------------------------------
class TestMcpAppSubprocess:
"""Verify MCPApp._run_with_reload subprocess behavior at runtime."""
def test_shutdown_sends_ctrl_break_on_win32(self) -> None:
"""On Windows, _run_with_reload sends CTRL_BREAK_EVENT for graceful child shutdown."""
from arcade_mcp_server.mcp_app import MCPApp
mock_proc = MagicMock()
mock_proc.wait.return_value = None
# sys.platform mock: exercises Windows graceful shutdown path with mocked Popen/signal.
with (
_patch_win32_subprocess_flags(),
patch.object(signal, "CTRL_BREAK_EVENT", WIN_CTRL_BREAK_EVENT, create=True),
patch.object(subprocess, "Popen", return_value=mock_proc),
patch("arcade_mcp_server.mcp_app.watch", side_effect=KeyboardInterrupt),
):
app = MCPApp()
app._run_with_reload("127.0.0.1", 8000)
mock_proc.send_signal.assert_called_once_with(WIN_CTRL_BREAK_EVENT)
mock_proc.terminate.assert_not_called()
def test_shutdown_falls_back_to_terminate_on_win32_oserror(self) -> None:
"""On Windows, shutdown falls back to terminate() if send_signal raises OSError."""
from arcade_mcp_server.mcp_app import MCPApp
mock_proc = MagicMock()
mock_proc.send_signal.side_effect = OSError("process already exited")
mock_proc.wait.return_value = None
# sys.platform mock: exercises OSError fallback path with mocked Popen/signal.
with (
_patch_win32_subprocess_flags(),
patch.object(signal, "CTRL_BREAK_EVENT", WIN_CTRL_BREAK_EVENT, create=True),
patch.object(subprocess, "Popen", return_value=mock_proc),
patch("arcade_mcp_server.mcp_app.watch", side_effect=KeyboardInterrupt),
):
app = MCPApp()
app._run_with_reload("127.0.0.1", 8000)
mock_proc.terminate.assert_called_once()
@pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows terminate() path")
def test_shutdown_calls_terminate_on_non_windows(self) -> None:
"""On Linux/macOS, _run_with_reload uses terminate() for graceful child shutdown."""
from arcade_mcp_server.mcp_app import MCPApp
mock_proc = MagicMock()
mock_proc.wait.return_value = None
with (
patch.object(subprocess, "Popen", return_value=mock_proc),
patch("arcade_mcp_server.mcp_app.watch", side_effect=KeyboardInterrupt),
):
app = MCPApp()
app._run_with_reload("127.0.0.1", 8000)
mock_proc.terminate.assert_called_once()
mock_proc.send_signal.assert_not_called()
# ---------------------------------------------------------------------------
# stdio.py — signal handler fallback
# ---------------------------------------------------------------------------
class TestStdioSignalFallback:
"""Verify stdio transport registers a stdlib signal.signal fallback on Windows."""
@pytest.mark.asyncio
async def test_registers_stdlib_signal_handler_on_windows(self) -> None:
"""On Windows, StdioTransport.start() calls signal.signal(SIGINT, ...) as fallback."""
import arcade_mcp_server.transports.stdio as stdio_mod
from arcade_mcp_server.transports.stdio import StdioTransport
transport = StdioTransport(name="test-win32-sigint")
registered_signals: list[int] = []
def capture_signal(signum: int, handler: object) -> None:
registered_signals.append(signum)
# sys.platform mock: exercises NotImplementedError fallback path that
# only occurs on Windows when asyncio signal handlers are unavailable.
with patch.object(sys, "platform", "win32"):
loop = asyncio.get_running_loop()
original_add = loop.add_signal_handler
def raise_not_impl(*args: object, **kwargs: object) -> None:
raise NotImplementedError
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
with patch.object(stdio_mod.signal, "signal", side_effect=capture_signal):
try:
await transport.start()
finally:
loop.add_signal_handler = original_add # type: ignore[assignment]
await transport.stop()
assert signal.SIGINT in registered_signals, (
"StdioTransport must register signal.signal(SIGINT, ...) on Windows "
"as asyncio fallback; registered signals: "
f"{registered_signals}"
)