From cdb6381758a0e4245f0ba062eef207bb0fe24b55 Mon Sep 17 00:00:00 2001 From: Oleh Prypin Date: Sat, 2 Dec 2023 23:19:01 +0100 Subject: [PATCH] Add diagnostics when `mkdocs serve` fails to bind a port Suggest the user to pick some other port - the suggested number seems random but is actually derived from the current site's name. --- mkdocs/commands/serve.py | 72 +++++++++++++++++++++++++++++++++-- mkdocs/livereload/__init__.py | 21 +++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py index e29db750..60e5b251 100644 --- a/mkdocs/commands/serve.py +++ b/mkdocs/commands/serve.py @@ -1,15 +1,21 @@ from __future__ import annotations +import hashlib +import json import logging +import os.path import shutil import tempfile +import urllib.request from os.path import isdir, isfile, join -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Mapping +from urllib.error import HTTPError from urllib.parse import urlsplit from mkdocs.commands.build import build from mkdocs.config import load_config -from mkdocs.livereload import LiveReloadServer, _serve_url +from mkdocs.exceptions import Abort +from mkdocs.livereload import LiveReloadServer, ServerBindError, _serve_url if TYPE_CHECKING: from mkdocs.config.defaults import MkDocsConfig @@ -61,6 +67,12 @@ def serve( if port is None: port = DEFAULT_PORT + origin_info = dict( + path=os.path.dirname(config.config_file_path) if config.config_file_path else os.getcwd(), + site_name=config.site_name, + site_url=config.site_url, + ) + mount_path = urlsplit(config.site_url or '/').path config.site_url = serve_url = _serve_url(host, port, mount_path) @@ -73,7 +85,12 @@ def serve( build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty) server = LiveReloadServer( - builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path + builder=builder, + host=host, + port=port, + root=site_dir, + mount_path=mount_path, + origin_info=origin_info, ) def error_handler(code) -> bytes | None: @@ -108,6 +125,11 @@ def serve( try: server.serve(open_in_browser=open_in_browser) + except ServerBindError as e: + log.error(f"Could not start a server on port {port}: {e}") + msg = diagnose_taken_port(port, url=server.url, origin_info=origin_info) + raise Abort(msg) + except KeyboardInterrupt: log.info("Shutting down...") finally: @@ -116,3 +138,47 @@ def serve( config.plugins.on_shutdown() if isdir(site_dir): shutil.rmtree(site_dir) + + +def diagnose_taken_port(port: int, *, url: str, origin_info: Mapping[str, Any]) -> str: + origin_info = dict(origin_info) + path: str = origin_info.pop('path') + + message = f"Attempted to listen on port {port} but " + other_info = None + try: + with urllib.request.urlopen(f'http://127.0.0.1:{port}/livereload/.info.json') as resp: + if resp.status == 200: + other_info = json.load(resp) + except HTTPError as e: + message += "some unrecognized HTTP server is already running on that port." + server = e.headers.get('server') + if server: + message += f" ({server!r})" + except ValueError: + message += "some unrecognized HTTP server is already running on that port." + except Exception: + message += "failed. And there isn't an HTTP server running on that port, but maybe another process is occupying it anyway." + + if other_info: + message += "a live-reload server is already running on that port." + if other_info['origin_info'].get('path') == path and other_info.get('url') == url: + message += f"\nIt actually serves the same path '{path}', try simply visiting {url}" + else: + message += f" It serves a different path '{path}'." + + if "the same path" not in message: + new_port = get_random_port(origin_info) + message += ( + f"\n\nTry serving on another port by passing the flag `-p {new_port}` (as an example)." + ) + if port == DEFAULT_PORT: + message += f" Or permanently use a distinct port for this site by adding `serve_port: {new_port}` to its config." + + return message + + +def get_random_port(origin_info: dict[str, Any]) -> int: + """Produce a "random" port number in range 8001-8064 that is reproducible for the current site.""" + hasher = hashlib.sha256(json.dumps(origin_info, sort_keys=True).encode()) + return DEFAULT_PORT + 1 + hasher.digest()[0] % 64 diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py index 70d44ba2..aa789f90 100644 --- a/mkdocs/livereload/__init__.py +++ b/mkdocs/livereload/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools import io import ipaddress +import json import logging import mimetypes import os @@ -21,7 +22,7 @@ import urllib.parse import webbrowser import wsgiref.simple_server import wsgiref.util -from typing import Any, BinaryIO, Callable, Iterable +from typing import Any, BinaryIO, Callable, Iterable, Mapping import watchdog.events import watchdog.observers.polling @@ -104,8 +105,11 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe mount_path: str = "/", polling_interval: float = 0.5, shutdown_delay: float = 0.25, + *, + origin_info: Mapping[str, Any] | None = None, ) -> None: self.builder = builder + self._host = host try: if isinstance(ipaddress.ip_address(host), ipaddress.IPv6Address): self.address_family = socket.AF_INET6 @@ -116,6 +120,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe self.url = _serve_url(host, port, mount_path) self.build_delay = 0.1 self.shutdown_delay = shutdown_delay + self._origin_info = origin_info # To allow custom error pages. self.error_handler: Callable[[int], bytes | None] = lambda code: None @@ -170,7 +175,10 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe self.observer.unschedule(self._watch_refs.pop(path)) def serve(self, *, open_in_browser=False): - self.server_bind() + try: + self.server_bind() + except OSError as e: + raise ServerBindError(str(e)) from e self.server_activate() if self._watched_paths: @@ -282,6 +290,11 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe self._epoch_cond.wait_for(condition, timeout=self.poll_response_timeout) return [b"%d" % self._visible_epoch] + elif path == "/livereload/.info.json": + start_response("200 OK", [("Content-Type", "application/json")]) + info = dict(origin_info=self._origin_info, url=self.url) + return [json.dumps(info).encode()] + if (path + "/").startswith(self.mount_path): rel_file_path = path[len(self.mount_path) :] @@ -367,6 +380,10 @@ class _Handler(wsgiref.simple_server.WSGIRequestHandler): log.debug(format, *args) +class ServerBindError(OSError): + pass + + def _timestamp() -> int: return round(time.monotonic() * 1000)