diff --git a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py index 0666fde72..4ad7c26af 100644 --- a/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py +++ b/apps/memos-local-plugin/adapters/hermes/memos_provider/daemon_manager.py @@ -292,7 +292,16 @@ def _probe_json_url(url: str) -> dict | str: except urllib.error.URLError as err: reason = getattr(err, "reason", None) errno = getattr(reason, "errno", None) - if errno in {61, 111}: # macOS/Linux connection refused + # 61 = macOS ECONNREFUSED, 111 = Linux ECONNREFUSED, + # 10061 = Windows WSAECONNREFUSED. See issue #2218. + if errno in {61, 111, 10061}: + return "free" + # Robust cross-platform check: Python raises ConnectionRefusedError + # consistently regardless of OS errno or locale-specific message + # text, so trust the exception type even when errno is missing or + # the message is localised (e.g. Czech Windows: "cílový počítač je + # aktivně odmítl"). + if isinstance(reason, ConnectionRefusedError): return "free" msg = str(err).lower() if "connection refused" in msg or "failed to establish" in msg: diff --git a/apps/memos-local-plugin/tests/python/test_bridge_client.py b/apps/memos-local-plugin/tests/python/test_bridge_client.py index b5c9eb917..f3370cee4 100644 --- a/apps/memos-local-plugin/tests/python/test_bridge_client.py +++ b/apps/memos-local-plugin/tests/python/test_bridge_client.py @@ -18,6 +18,7 @@ import threading import time import unittest +import urllib.error from pathlib import Path from unittest.mock import patch @@ -1370,6 +1371,81 @@ def busy_lock(): popen.assert_not_called() +class ProbeJsonUrlConnectionRefusedTests(unittest.TestCase): + """Regression tests for issue #2218. + + `_probe_json_url()` used to recognise only macOS/Linux ECONNREFUSED + errno values (61, 111) and the English "connection refused" phrase. + On Windows the socket raises errno 10061 (WSAECONNREFUSED) and a + locale-dependent message (Czech: "cílový počítač je aktivně odmítl"), + which matched neither branch — so an unused port was permanently + misclassified as "blocked" and the viewer panel never launched. + + The fix: recognise errno 10061, and (more importantly) trust the + `ConnectionRefusedError` type check that Python raises consistently + across all platforms regardless of locale. + """ + + def _make_urlerror(self, reason) -> urllib.error.URLError: + return urllib.error.URLError(reason) + + def _run_probe(self, urlerror) -> str: + with patch.object( + daemon_manager_mod.urllib.request, + "urlopen", + side_effect=urlerror, + ): + return daemon_manager_mod._probe_json_url("http://127.0.0.1:18800/api/v1/ping") + + def test_windows_wsaeconnrefused_errno_10061_reports_free(self) -> None: + # Simulate the Windows socket path: ConnectionRefusedError with the + # Windows-specific WSAECONNREFUSED errno (10061). Before the fix, the + # errno check {61, 111} missed 10061 and the locale-dependent Windows + # message ("cílový počítač je aktivně odmítl" in Czech) never matched + # the English "connection refused" substring, so the port was + # misclassified as "blocked". + reason = ConnectionRefusedError(10061, "cílový počítač je aktivně odmítl") + result = self._run_probe(self._make_urlerror(reason)) + self.assertEqual(result, "free") + + def test_windows_localised_message_without_english_phrase_reports_free(self) -> None: + # Belt-and-suspenders: even if the reason is a bare OSError (no + # ConnectionRefusedError type), errno 10061 alone must be enough to + # classify the port as free — the errno set is platform-portable. + reason = OSError(10061, "cílový počítač je aktivně odmítl") + result = self._run_probe(self._make_urlerror(reason)) + self.assertEqual(result, "free") + + def test_connection_refused_type_check_is_locale_agnostic(self) -> None: + # The most robust signal is the exception type: Python raises + # ConnectionRefusedError whenever the OS refuses the connection, + # regardless of platform, errno, or message locale. Even if the + # errno is missing/unknown, the type alone must classify as free. + reason = ConnectionRefusedError() # no errno, no message + result = self._run_probe(self._make_urlerror(reason)) + self.assertEqual(result, "free") + + def test_macos_errno_61_still_reports_free(self) -> None: + # Original behaviour on macOS: ECONNREFUSED errno 61. + reason = ConnectionRefusedError(61, "Connection refused") + result = self._run_probe(self._make_urlerror(reason)) + self.assertEqual(result, "free") + + def test_linux_errno_111_still_reports_free(self) -> None: + # Original behaviour on Linux: ECONNREFUSED errno 111. + reason = ConnectionRefusedError(111, "Connection refused") + result = self._run_probe(self._make_urlerror(reason)) + self.assertEqual(result, "free") + + def test_non_refusal_urlerror_still_reports_blocked(self) -> None: + # Any other URLError (DNS failure, unrelated socket error, etc.) + # must still be classified as "blocked" — we only widen the "free" + # branch, never the fall-through. + reason = OSError(13, "Permission denied") + result = self._run_probe(self._make_urlerror(reason)) + self.assertEqual(result, "blocked") + + class BridgeOkCacheTests(unittest.TestCase): """Regression tests for issue #1797.