Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions mkdocs/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import logging
import shutil
import sys
import tempfile
from os.path import isdir, isfile, join
from signal import SIGINT, SIGTERM, signal, strsignal
from typing import TYPE_CHECKING
from urllib.parse import urlsplit

Expand Down Expand Up @@ -78,8 +80,43 @@ def error_handler(code) -> bytes | None:
return f.read()
return None

def handle_signal(signum, frame) -> None:
signal_name = strsignal(signum)
log.info(f"Received signal '{signal_name}'")

shutdown()

def shutdown() -> None:
if not server.is_active:
return

log.info("Shutting down...")

try:
server.shutdown()
finally:
config.plugins.on_shutdown()

if isdir(site_dir):
shutil.rmtree(site_dir)

server.error_handler = error_handler

signal(SIGTERM, handle_signal)
signal(SIGINT, handle_signal)

if sys.platform == "linux":
from signal import SIGHUP

signal(SIGHUP, handle_signal)

elif sys.platform == "win32":
from signal import CTRL_BREAK_EVENT, CTRL_C_EVENT, SIGBREAK

signal(SIGBREAK, handle_signal)
signal(CTRL_C_EVENT, handle_signal)
signal(CTRL_BREAK_EVENT, handle_signal)

try:
# Perform the initial build
builder(config)
Expand All @@ -100,13 +137,7 @@ def error_handler(code) -> bytes | None:
for item in config.watch:
server.watch(item)

try:
server.serve(open_in_browser=open_in_browser)
except KeyboardInterrupt:
log.info("Shutting down...")
finally:
server.shutdown()
server.serve(open_in_browser=open_in_browser)

finally:
config.plugins.on_shutdown()
if isdir(site_dir):
shutil.rmtree(site_dir)
shutdown()
8 changes: 8 additions & 0 deletions mkdocs/livereload/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ def __init__(
self.address_family = socket.AF_INET6
except Exception:
pass

self._is_active = False
self.root = os.path.abspath(root)
self.mount_path = _normalize_mount_path(mount_path)
self.url = _serve_url(host, port, mount_path)
Expand Down Expand Up @@ -170,6 +172,7 @@ def unwatch(self, path: str) -> None:
self.observer.unschedule(self._watch_refs.pop(path))

def serve(self, *, open_in_browser=False):
self._is_active = True
self.server_bind()
self.server_activate()

Expand Down Expand Up @@ -225,6 +228,7 @@ def _build_loop(self):
self._epoch_cond.notify_all()

def shutdown(self, wait=False) -> None:
self._is_active = False
self.observer.stop()
with self._rebuild_cond:
self._shutdown = True
Expand Down Expand Up @@ -261,6 +265,10 @@ def serve_request(self, environ, start_response) -> Iterable[bytes]:
start_response(msg, [("Content-Type", "text/html")])
return [error_content]

@property
def is_active(self) -> bool:
return self._is_active
Comment thread
pawamoy marked this conversation as resolved.

def _serve_request(self, environ, start_response) -> Iterable[bytes] | None:
# https://bugs.python.org/issue16679
# https://github.com/bottlepy/bottle/blob/f9b1849db4/bottle.py#L984
Expand Down
186 changes: 186 additions & 0 deletions mkdocs/tests/shutdown_by_signal_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
from __future__ import annotations

import unittest
from dataclasses import dataclass
from pathlib import Path
from subprocess import Popen
from tempfile import TemporaryDirectory
from time import sleep


@dataclass
class Sample_Repository:
handler: TemporaryDirectory
site_dir: str
signature: str

def __del__(self):
from sys import platform

if platform == "linux":
self.handler.cleanup()


class Shutdown_by_signal_tests(unittest.TestCase):
SLEEPING_TIME_WAITING_FOR_START = 2
SLEEPING_TIME_WAITING_FOR_SHUTDOWN = 2
SLEEPING_TIME_WAITING_FOR_PROCESS = 6
TRIES_TO_CHECK_FOR_DIRECTORY_CLEANUP = 4

def _create_sample_repository(self) -> Sample_Repository:
from os import mkdir
from uuid import uuid4

from yaml import dump

handler = TemporaryDirectory(prefix='mkdocs_test-')
site_dir = handler.name

signature = uuid4().hex

configuration = {'site_name': 'Testing case', 'docs_dir': 'docs'}

docs_dir = f"{site_dir}/{configuration.get('docs_dir')}"
mkdir(docs_dir)

Path(site_dir, "mkdocs.yml").write_text(dump(configuration))
Path(docs_dir, "index.md").write_text("# Index File")
Path(docs_dir, "signature.md").write_text(f"# {signature} ")

return Sample_Repository(
handler=handler,
site_dir=site_dir,
signature=signature,
)

def _execute_mkdocs_as_liveserver(self, site_dir: str) -> Popen:
from os import chdir, getcwd
from subprocess import DEVNULL
from sys import platform

current_working_dir = getcwd()
chdir(site_dir)

if platform == "linux":
from errno import EADDRINUSE
from socket import AF_INET, SOCK_STREAM, socket

port_testing = socket(AF_INET, SOCK_STREAM)

try:
port_testing.bind(("127.0.0.1", 8000))
except OSError as exception:
if exception.errno == EADDRINUSE:
Popen('killall mkdocs'.split(' ')).wait(
timeout=self.SLEEPING_TIME_WAITING_FOR_PROCESS
)

port_testing.close()

mkdocs = Popen('mkdocs serve'.split(' '), stdout=DEVNULL, stderr=DEVNULL, shell=False)
sleep(self.SLEEPING_TIME_WAITING_FOR_START)

chdir(current_working_dir)

return mkdocs

def _locate_mkdocs_directory(self, signature: str) -> Path | None:
from pathlib import Path
from tempfile import TemporaryDirectory

temporary_directory_probe = TemporaryDirectory()
temporary_directory = Path(f"{temporary_directory_probe.name}").parent

for mkdocs_temporary_directory in temporary_directory.glob('mkdocs_*'):
mkdocs_temporary_path = Path(mkdocs_temporary_directory)
mkdocs_signature_path = Path(f"{mkdocs_temporary_path}/signature/index.html")

if not mkdocs_temporary_path.exists():
continue

if not mkdocs_temporary_path.is_dir():
continue

if not mkdocs_signature_path.exists():
continue

with mkdocs_signature_path.open('r') as fp:
signature_found = fp.read()

if signature_found.find(signature) == -1:
continue

temporary_directory_probe.cleanup()

return mkdocs_temporary_path

return None

def _is_active(self, mkdocs: Popen) -> bool:
from errno import ESRCH
from os import kill
from signal import SIG_BLOCK

if mkdocs.returncode is not None:
return False

try:
kill(mkdocs.pid, SIG_BLOCK)
except OSError as exception:
if exception.errno == ESRCH:
return True

return False

def _wait_for_shutdown(self, mkdocs: Popen, signal: int):
from os import kill

kill(mkdocs.pid, signal)

while self._is_active(mkdocs):
sleep(self.SLEEPING_TIME_WAITING_FOR_SHUTDOWN)

def _was_directory_cleaned(self, path: Path) -> bool:
for _ in range(self.TRIES_TO_CHECK_FOR_DIRECTORY_CLEANUP):
if not path.exists():
return True

sleep(self.SLEEPING_TIME_WAITING_FOR_PROCESS)

return False

def test_shutdown_with_signal(self):
from signal import SIGINT, SIGTERM, strsignal

repository = self._create_sample_repository()

for signal in [SIGINT, SIGTERM]:
mkdocs = self._execute_mkdocs_as_liveserver(repository.site_dir)
mkdocs_temporary_path = self._locate_mkdocs_directory(signature=repository.signature)

self.assertTrue(
mkdocs_temporary_path is not None, "Unable to locate the live server directory"
)

self._wait_for_shutdown(mkdocs, signal)

was_cleaned = self._was_directory_cleaned(mkdocs_temporary_path)

self.assertTrue(
was_cleaned,
f"The Mkdocs '{mkdocs_temporary_path}' was not cleaned "
f"with signal '{strsignal(signal)}'",
)

# This is a flaw in 'liveserver' shutdown process
# it seems mkdocs become zombie when testing.
while mkdocs.returncode is None:
mkdocs.wait(timeout=self.SLEEPING_TIME_WAITING_FOR_PROCESS)

del mkdocs

del repository


if __name__ == '__main__':
Shutdown_by_signal_tests()