<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Touches authentication/login flow, credentials-file permissions, and subprocess lifecycle behavior across platforms; while mostly defensive, regressions could impact login or process management on Windows/macOS runners. > > **Overview** > Improves Windows/cross-platform reliability across the CLI and MCP server: OAuth login now binds the callback server to `127.0.0.1`, avoids slow loopback reverse-DNS, adds a configurable callback timeout (`--timeout` + env default), and opens URLs via a Windows-friendly `_open_browser` to avoid flashing console windows. > > Centralizes CLI output via a shared `console` that forces UTF-8 on Windows, standardizes UTF-8 file reads/writes throughout, tightens credentials-file permissions on Windows using `icacls`, and adds shared Windows subprocess helpers for **no-window** process creation and graceful termination (used by `deploy`, MCP reload, and usage-tracking worker). > > Updates client configuration UX/robustness (Windows AppData resolution via `platformdirs`, Cursor config path fallbacks + compatibility writes, overwrite warnings, absolute `uv` path for GUI clients, safer path display) and improves `deploy` child-process handling to avoid pipe-buffer deadlocks while giving better debug-aware error messages. > > Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI integration workflow, disables usage tracking in toolkits CI, and adds extensive regression tests for Windows signals, subprocess cleanup, UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and `arcade-mcp-server` to `1.17.2` (with updated dependency pin). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
218 lines
7.8 KiB
Python
218 lines
7.8 KiB
Python
"""Tests for Windows signal handling in stdio transport.
|
|
|
|
Verifies that:
|
|
- The signal-handler support message is suppressed on Windows.
|
|
- No noisy "Failed to set up signal handler" warning is logged on Windows.
|
|
- A stdlib signal.signal(SIGINT) fallback is registered on Windows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signal_handler_support_message_is_suppressed_on_windows() -> None:
|
|
"""On Windows, don't log a user-facing signal-support message."""
|
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
|
|
|
transport = StdioTransport(name="test-stdio")
|
|
|
|
log_records: list[logging.LogRecord] = []
|
|
|
|
class RecordHandler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
log_records.append(record)
|
|
|
|
logger = logging.getLogger("arcade.mcp.transports.stdio")
|
|
handler = RecordHandler()
|
|
logger.addHandler(handler)
|
|
original_level = logger.level
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
try:
|
|
with patch.object(sys, "platform", "win32"):
|
|
# Simulate the NotImplementedError that Windows raises for
|
|
# loop.add_signal_handler.
|
|
loop = asyncio.get_running_loop()
|
|
original_add = loop.add_signal_handler
|
|
|
|
def raise_not_impl(*args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
|
|
|
# Also mock signal.signal so we don't actually install a handler
|
|
with patch("arcade_mcp_server.transports.stdio.signal.signal"):
|
|
try:
|
|
await transport.start()
|
|
finally:
|
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
|
await transport.stop()
|
|
|
|
messages = [r.getMessage() for r in log_records]
|
|
assert not any("Windows does not support asyncio signal handlers" in m for m in messages), (
|
|
"Windows signal support message should be suppressed."
|
|
)
|
|
finally:
|
|
logger.removeHandler(handler)
|
|
logger.setLevel(original_level)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signal_handler_no_failed_setup_warning_on_windows() -> None:
|
|
"""On Windows, avoid warning noise when asyncio signal handlers are unavailable."""
|
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
|
|
|
transport = StdioTransport(name="test-stdio-once")
|
|
|
|
log_records: list[logging.LogRecord] = []
|
|
|
|
class RecordHandler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
log_records.append(record)
|
|
|
|
logger = logging.getLogger("arcade.mcp.transports.stdio")
|
|
handler = RecordHandler()
|
|
logger.addHandler(handler)
|
|
original_level = logger.level
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
try:
|
|
with patch.object(sys, "platform", "win32"):
|
|
loop = asyncio.get_running_loop()
|
|
original_add = loop.add_signal_handler
|
|
|
|
def raise_not_impl(*args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
|
|
|
with patch("arcade_mcp_server.transports.stdio.signal.signal"):
|
|
try:
|
|
await transport.start()
|
|
finally:
|
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
|
await transport.stop()
|
|
|
|
failed_setup_warnings = [
|
|
r for r in log_records if "Failed to set up signal handler" in r.getMessage()
|
|
]
|
|
assert len(failed_setup_warnings) == 0, (
|
|
"Should not emit setup warnings for expected Windows asyncio limitations."
|
|
)
|
|
finally:
|
|
logger.removeHandler(handler)
|
|
logger.setLevel(original_level)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_signal_signal_fallback_registered_on_windows() -> None:
|
|
"""On Windows, signal.signal(SIGINT, ...) should be called as a fallback."""
|
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
|
|
|
transport = StdioTransport(name="test-stdio-fallback")
|
|
|
|
log_records: list[logging.LogRecord] = []
|
|
|
|
class RecordHandler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
log_records.append(record)
|
|
|
|
logger = logging.getLogger("arcade.mcp.transports.stdio")
|
|
handler = RecordHandler()
|
|
logger.addHandler(handler)
|
|
original_level = logger.level
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
try:
|
|
with patch.object(sys, "platform", "win32"):
|
|
loop = asyncio.get_running_loop()
|
|
original_add = loop.add_signal_handler
|
|
|
|
def raise_not_impl(*args, **kwargs):
|
|
raise NotImplementedError
|
|
|
|
loop.add_signal_handler = raise_not_impl # type: ignore[assignment]
|
|
|
|
with patch("arcade_mcp_server.transports.stdio.signal.signal") as mock_signal:
|
|
try:
|
|
await transport.start()
|
|
finally:
|
|
loop.add_signal_handler = original_add # type: ignore[assignment]
|
|
await transport.stop()
|
|
|
|
# signal.signal should have been called with SIGINT
|
|
import signal
|
|
sigint_calls = [
|
|
c for c in mock_signal.call_args_list
|
|
if c[0][0] == signal.SIGINT
|
|
]
|
|
assert len(sigint_calls) == 1, (
|
|
f"Expected signal.signal(SIGINT, ...) to be called once. "
|
|
f"All calls: {mock_signal.call_args_list}"
|
|
)
|
|
finally:
|
|
logger.removeHandler(handler)
|
|
logger.setLevel(original_level)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only SIGINT fallback behavior")
|
|
@pytest.mark.asyncio
|
|
async def test_windows_sigint_fallback_schedules_stop_on_transport_loop() -> None:
|
|
"""Windows SIGINT fallback should schedule stop() on the captured event loop."""
|
|
import signal
|
|
|
|
import arcade_mcp_server.transports.stdio as stdio_mod
|
|
from arcade_mcp_server.transports.stdio import StdioTransport
|
|
|
|
transport = StdioTransport(name="test-stdio-loop-schedule")
|
|
registered_handlers: dict[int, Callable[[int, object], None]] = {}
|
|
scheduled_callbacks: list[Callable[[], None]] = []
|
|
created_coroutines: list[Coroutine[Any, Any, None]] = []
|
|
|
|
def capture_signal(signum: int, handler: Callable[[int, object], None]) -> None:
|
|
registered_handlers[signum] = handler
|
|
|
|
def capture_call_soon_threadsafe(callback: Callable[[], None], *args: object) -> None:
|
|
assert not args
|
|
scheduled_callbacks.append(callback)
|
|
|
|
def capture_create_task(coro: Coroutine[Any, Any, None]) -> object:
|
|
created_coroutines.append(coro)
|
|
coro.close()
|
|
return object()
|
|
|
|
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[method-assign]
|
|
with (
|
|
patch.object(loop, "call_soon_threadsafe", side_effect=capture_call_soon_threadsafe),
|
|
patch.object(loop, "create_task", side_effect=capture_create_task),
|
|
patch.object(stdio_mod.signal, "signal", side_effect=capture_signal),
|
|
):
|
|
try:
|
|
await transport.start()
|
|
|
|
handler = registered_handlers[signal.SIGINT]
|
|
handler(signal.SIGINT, object())
|
|
|
|
assert len(scheduled_callbacks) == 1
|
|
scheduled_callback = scheduled_callbacks[0]
|
|
|
|
scheduled_callback()
|
|
assert len(created_coroutines) == 1
|
|
finally:
|
|
loop.add_signal_handler = original_add # type: ignore[method-assign]
|
|
await transport.stop()
|