From 423d21824058c14d7464f513f319e812edafbf56 Mon Sep 17 00:00:00 2001 From: autodev-bot Date: Wed, 5 Aug 2026 14:53:46 +0800 Subject: [PATCH 1/2] fix(memos-local-plugin): recognise Windows WSAECONNREFUSED in viewer port probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_probe_json_url()` only recognised macOS/Linux ECONNREFUSED errno values (61, 111) and the English "connection refused" substring, so on non-English Windows the socket raised errno 10061 with a locale-dependent message (e.g. Czech: "cílový počítač je aktivně odmítl") that matched neither branch. An unused port was therefore always misclassified as "blocked", the viewer daemon never started, and http://127.0.0.1:18800/ was permanently unreachable on fresh non-English Windows installs. Fix: - Add 10061 (WSAECONNREFUSED) to the errno whitelist. - Fall back to `isinstance(reason, ConnectionRefusedError)` — Python raises this type consistently across platforms regardless of errno or locale, and is the load-bearing signal even if errno is missing. Add 6 pytest cases in `ProbeJsonUrlConnectionRefusedTests` covering Windows errno 10061 (English + Czech messages), bare `ConnectionRefusedError`, macOS errno 61, Linux errno 111, and a "must stay blocked" negative case for unrelated OSError. Fixes #2218 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hermes/memos_provider/daemon_manager.py | 11 ++- .../tests/python/test_bridge_client.py | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) 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 82763fcea..c67b09c4f 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 @@ -1215,6 +1216,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) -> object: + 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. From faeead8ffae2d753bac7f79e8ce96e4a56d13474 Mon Sep 17 00:00:00 2001 From: autodev-bot Date: Wed, 5 Aug 2026 15:15:34 +0800 Subject: [PATCH 2/2] test(memos-local-plugin): tighten _run_probe return annotation to str Address OCR finding on PR #2220: `_run_probe` in `ProbeJsonUrlConnectionRefusedTests` was annotated `-> object`, which is uninformative. The helper always patches `urlopen` with a `side_effect=urlerror`, so `_probe_json_url` only ever reaches its `URLError` branches, all of which return `"free"` or `"blocked"`. All six test assertions compare the result against string literals. `-> str` makes the intent explicit and lets type checkers catch any accidental future change to a non-string return. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/memos-local-plugin/tests/python/test_bridge_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c67b09c4f..8edabb2d3 100644 --- a/apps/memos-local-plugin/tests/python/test_bridge_client.py +++ b/apps/memos-local-plugin/tests/python/test_bridge_client.py @@ -1234,7 +1234,7 @@ class ProbeJsonUrlConnectionRefusedTests(unittest.TestCase): def _make_urlerror(self, reason) -> urllib.error.URLError: return urllib.error.URLError(reason) - def _run_probe(self, urlerror) -> object: + def _run_probe(self, urlerror) -> str: with patch.object( daemon_manager_mod.urllib.request, "urlopen",