Files
Gérald LONLASandClaude Opus 5.5 4d50a97925 fix(network): explain macOS Local Network denials and ship a LAN forwarder (#20)
On macOS, an app without Local Network permission gets "[Errno 65] No route
to host" for every host on the local network, surfaced by the OpenAI client as
a bare "Connection error." Some launchers (Comfy Desktop 1.1.3) never request
the permission, so it cannot be granted in System Settings at all.

- network.py: detect the denial (darwin + EHOSTUNREACH anywhere in the
  exception chain + a LAN target) and raise LocalNetworkBlockedError with a
  fix built from the user's own server_url. Wired into Connect, the
  /lmstudio/test and /lmstudio/models routes, Text Gen and Image To Text.
  Other errors and platforms are untouched.
- scripts/lan_forward.py: stdlib-only relay from 127.0.0.1 (loopback is
  exempt) to the LM Studio host. --target is required; no built-in address.
  Probes the target on startup and explains failures.
- README: troubleshooting section. launchd agents are blocked the same way,
  so the start-at-login recipe is a .command file opened by Terminal.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:10:42 +08:00

138 lines
4.7 KiB
Python

"""Turn opaque connection failures into actionable messages.
macOS Local Network privacy blocks apps without the permission from reaching
any host on the local network, and the only symptom is a generic
"[Errno 65] No route to host" deep inside the OpenAI client. Some apps that
launch ComfyUI (Comfy Desktop, for one) never ask for the permission, so they
never appear in System Settings and the user has nothing to switch on.
Loopback is always allowed, which is what the bundled forwarder relies on.
"""
from __future__ import annotations
import errno
import ipaddress
import re
import socket
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from urllib.parse import urlparse
FORWARDER_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lan_forward.py"
# What macOS reports when Local Network privacy denies a connection.
_BLOCKED_ERRNOS = frozenset({errno.EHOSTUNREACH})
_ERRNO_IN_TEXT_RE = re.compile(r"\[Errno (\d+)\]")
class LocalNetworkBlockedError(ConnectionError):
"""macOS refused to let this process reach a host on the local network."""
def _exception_chain(exc: BaseException) -> Iterator[BaseException]:
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
yield current
current = current.__cause__ or current.__context__
def find_errno(exc: BaseException) -> int | None:
"""First OS error number anywhere in the exception chain."""
for item in _exception_chain(exc):
if isinstance(item, OSError) and item.errno:
return item.errno
match = _ERRNO_IN_TEXT_RE.search(str(item))
if match:
return int(match.group(1))
return None
def _is_lan_ip(address: str) -> bool:
try:
ip = ipaddress.ip_address(address.split("%", 1)[0])
except ValueError:
return False
return (ip.is_private or ip.is_link_local) and not ip.is_loopback
def is_local_network_host(host: str | None) -> bool:
"""True when `host` is (or resolves to) a non-loopback local network address."""
if not host:
return False
if _is_lan_ip(host):
return True
try:
ipaddress.ip_address(host.split("%", 1)[0])
return False # a literal IP that is loopback or public
except ValueError:
pass
if host.endswith(".local") or "." not in host.rstrip("."):
return host.lower() != "localhost"
try:
infos = socket.getaddrinfo(host, None)
except OSError:
return False
return any(_is_lan_ip(str(info[4][0])) for info in infos)
def _format_host(host: str) -> str:
return f"[{host}]" if ":" in host else host
def local_network_hint(
exc: BaseException, server_url: str, platform: str | None = None
) -> str | None:
"""Explain a macOS Local Network privacy denial, or None if that is not what happened."""
if (platform or sys.platform) != "darwin":
return None
if find_errno(exc) not in _BLOCKED_ERRNOS:
return None
parsed = urlparse(server_url)
host = parsed.hostname
if not is_local_network_host(host):
return None
scheme = parsed.scheme or "http"
port = parsed.port or (443 if scheme == "https" else 80)
target = f"{_format_host(host)}:{port}"
lines = [
f"macOS blocked ComfyUI from reaching the LM Studio server at {server_url} "
'("No route to host").',
"",
"This is macOS Local Network privacy, not a network fault: the app that runs "
f"ComfyUI is not allowed to talk to devices on your local network, even though "
f"other apps can reach {host}.",
"",
"Fix it one of these ways:",
" 1. System Settings > Privacy & Security > Local Network: switch on the app "
"that runs ComfyUI (Terminal, iTerm, Comfy Desktop...), then quit it fully "
"(Cmd+Q) and relaunch.",
" 2. If that app is not in the list (some apps, such as Comfy Desktop, never "
"ask for the permission), run the bundled forwarder in a Terminal window and "
"leave it open:",
f' python3 "{FORWARDER_SCRIPT}" --target {target}',
f" then set server_url to {scheme}://127.0.0.1:{port}",
]
if scheme == "https":
lines.append(
" (with https, the server certificate must also be valid for 127.0.0.1)"
)
return "\n".join(lines)
@contextmanager
def explain_network_errors(server_url: str) -> Iterator[None]:
"""Re-raise a macOS Local Network denial as LocalNetworkBlockedError with a fix."""
try:
yield
except Exception as exc:
hint = local_network_hint(exc, server_url)
if hint is None:
raise
raise LocalNetworkBlockedError(hint) from exc