Skip to content

Commit

Permalink
Add diagnostics when mkdocs serve fails to bind a port
Browse files Browse the repository at this point in the history
Suggest the user to pick some other port - the suggested number seems random but is actually derived from the current site's name.
  • Loading branch information
oprypin committed Dec 3, 2023
1 parent ed38f8d commit cdb6381
Show file tree
Hide file tree
Showing 2 changed files with 88 additions and 5 deletions.
72 changes: 69 additions & 3 deletions 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
Expand Down Expand Up @@ -61,6 +67,12 @@ def get_config():
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)

Expand All @@ -73,7 +85,12 @@ def builder(config: MkDocsConfig | None = None):
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:
Expand Down Expand Up @@ -108,6 +125,11 @@ def error_handler(code) -> bytes | None:

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:
Expand All @@ -116,3 +138,47 @@ def error_handler(code) -> bytes | None:
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
21 changes: 19 additions & 2 deletions mkdocs/livereload/__init__.py
Expand Up @@ -3,6 +3,7 @@
import functools
import io
import ipaddress
import json
import logging
import mimetypes
import os
Expand All @@ -21,7 +22,7 @@
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
Expand Down Expand Up @@ -104,8 +105,11 @@ def __init__(
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
Expand All @@ -116,6 +120,7 @@ def __init__(
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

Expand Down Expand Up @@ -170,7 +175,10 @@ def unwatch(self, path: str) -> None:
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:
Expand Down Expand Up @@ -282,6 +290,11 @@ def condition():
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) :]

Expand Down Expand Up @@ -367,6 +380,10 @@ def log_message(self, format, *args):
log.debug(format, *args)


class ServerBindError(OSError):
pass


def _timestamp() -> int:
return round(time.monotonic() * 1000)

Expand Down

0 comments on commit cdb6381

Please sign in to comment.