mirror of
https://github.com/mkdocs/mkdocs.git
synced 2026-03-27 09:58:31 +07:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user