Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix reloader on OSX py38 and Windows #1827

Merged
merged 25 commits into from
Jun 3, 2020
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
59ccf71
Fix watchdog reload worker repeatedly if there are multiple changed f…
lexhung Apr 18, 2019
8ebac9b
Simplify autoreloader, don't need multiprocessing.Process. Now works …
Tronic Mar 31, 2020
1bf7e2f
Allow autoreloader with multiple workers and run it earlier.
Tronic Mar 31, 2020
c1bab69
This works OK on Windows too.
Tronic Mar 31, 2020
d04fbfc
I don't see how cwd could be different here.
Tronic Mar 31, 2020
261d15e
app.run and app.create_server argument fixup.
Tronic Mar 31, 2020
2cb4107
Add test for auto_reload (coverage not working unfortunately).
Tronic Mar 31, 2020
e522c72
Reloader cleanup, don't use external kill commands and exit normally.
Tronic Mar 31, 2020
613086a
Strip newlines on test output (Windows-compat).
Tronic Mar 31, 2020
58a1226
Report failures in test_auto_reload to avoid timeouts.
Tronic Mar 31, 2020
3c4a280
Use different test server ports to avoid binding problems on Windows.
Tronic Apr 1, 2020
2cadf51
Fix previous commit
Tronic Apr 1, 2020
0167708
Listen on same port after reload.
Tronic Apr 1, 2020
2504a62
Show Goin' Fast banner on reloads.
Tronic Apr 1, 2020
6a3c462
More robust testing, also -m sanic.
Tronic Apr 1, 2020
b314b4b
Add a timeout to terminate process
Tronic Apr 1, 2020
b3d1d13
Try a workaround for tmpdir deletion on Windows.
Tronic Apr 1, 2020
e601238
Join process also on error (context manager doesn't).
Tronic Apr 1, 2020
c870752
Cleaner autoreloader termination on Windows.
Tronic Apr 1, 2020
1859331
Remove unused code.
Tronic Apr 1, 2020
ae6ab3f
Rename test.
Tronic Apr 1, 2020
c975cb1
Longer timeout on test exit.
Tronic Apr 1, 2020
0a60c31
Merge branch 'master' into reloader
Tronic Apr 7, 2020
800274a
Merge branch 'master' into reloader
ahopkins Jun 3, 2020
b636083
Merge branch 'master' into reloader
ahopkins Jun 3, 2020
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
35 changes: 13 additions & 22 deletions sanic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,9 @@ def run(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
debug: bool = False,
auto_reload: Optional[bool] = None,
ssl: Union[dict, SSLContext, None] = None,
sock: Optional[socket] = None,
workers: int = 1,
Expand All @@ -1067,7 +1069,7 @@ def run(
stop_event: Any = None,
register_sys_signals: bool = True,
access_log: Optional[bool] = None,
**kwargs: Any,
ahopkins marked this conversation as resolved.
Show resolved Hide resolved
loop: None = None,
) -> None:
"""Run the HTTP Server and listen until keyboard interrupt or term
signal. On termination, drain connections before closing.
Expand All @@ -1078,6 +1080,9 @@ def run(
:type port: int
:param debug: Enables debug output (slows server)
:type debug: bool
:param auto_reload: Reload app whenever its source code is changed.
Enabled by default in debug mode.
:type auto_relaod: bool
:param ssl: SSLContext, or location of certificate and key
for SSL encryption of worker(s)
:type ssl: SSLContext or dict
Expand All @@ -1099,21 +1104,17 @@ def run(
:type access_log: bool
:return: Nothing
"""
if "loop" in kwargs:
if loop is not None:
raise TypeError(
"loop is not a valid argument. To use an existing loop, "
"change to create_server().\nSee more: "
"https://sanic.readthedocs.io/en/latest/sanic/deploying.html"
"#asynchronous-support"
)

# Default auto_reload to false
auto_reload = False
# If debug is set, default it to true (unless on windows)
if debug and os.name == "posix":
auto_reload = True
# Allow for overriding either of the defaults
auto_reload = kwargs.get("auto_reload", auto_reload)
if auto_reload or auto_reload is None and debug:
if os.environ.get("SANIC_SERVER_RUNNING") != "true":
return reloader_helpers.watchdog(1.0)

if sock is None:
host, port = host or "127.0.0.1", port or 8000
Expand Down Expand Up @@ -1156,18 +1157,7 @@ def run(
)
workers = 1
if workers == 1:
if auto_reload and os.name != "posix":
# This condition must be removed after implementing
# auto reloader for other operating systems.
raise NotImplementedError

if (
auto_reload
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
):
reloader_helpers.watchdog(2)
else:
serve(**server_settings)
serve(**server_settings)
else:
serve_multiple(server_settings, workers)
except BaseException:
Expand All @@ -1189,6 +1179,7 @@ async def create_server(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
debug: bool = False,
ssl: Union[dict, SSLContext, None] = None,
sock: Optional[socket] = None,
Expand Down Expand Up @@ -1413,7 +1404,7 @@ def _helper(
server_settings["run_async"] = True

# Serve
if host and port and os.environ.get("SANIC_SERVER_RUNNING") != "true":
if host and port:
proto = "http"
if ssl is not None:
proto = "https"
Expand Down
146 changes: 41 additions & 105 deletions sanic/reloader_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import subprocess
import sys

from multiprocessing import Process
from time import sleep


Expand Down Expand Up @@ -35,133 +34,70 @@ def _iter_module_files():

def _get_args_for_reloading():
"""Returns the executable."""
rv = [sys.executable]
main_module = sys.modules["__main__"]
mod_spec = getattr(main_module, "__spec__", None)
if sys.argv[0] in ("", "-c"):
raise RuntimeError(
f"Autoreloader cannot work with argv[0]={sys.argv[0]!r}"
)
if mod_spec:
# Parent exe was launched as a module rather than a script
rv.extend(["-m", mod_spec.name])
if len(sys.argv) > 1:
rv.extend(sys.argv[1:])
else:
rv.extend(sys.argv)
return rv
return [sys.executable, "-m", mod_spec.name] + sys.argv[1:]
return [sys.executable] + sys.argv


def restart_with_reloader():
"""Create a new process and a subprocess in it with the same arguments as
this one.
"""
cwd = os.getcwd()
args = _get_args_for_reloading()
new_environ = os.environ.copy()
new_environ["SANIC_SERVER_RUNNING"] = "true"
cmd = " ".join(args)
worker_process = Process(
target=subprocess.call,
args=(cmd,),
kwargs={"cwd": cwd, "shell": True, "env": new_environ},
return subprocess.Popen(
_get_args_for_reloading(),
env={**os.environ, "SANIC_SERVER_RUNNING": "true"},
)
worker_process.start()
return worker_process


def kill_process_children_unix(pid):
"""Find and kill child processes of a process (maximum two level).

:param pid: PID of parent process (process ID)
:return: Nothing
"""
root_process_path = f"/proc/{pid}/task/{pid}/children"
if not os.path.isfile(root_process_path):
return
with open(root_process_path) as children_list_file:
children_list_pid = children_list_file.read().split()

for child_pid in children_list_pid:
children_proc_path = "/proc/%s/task/%s/children" % (
child_pid,
child_pid,
)
if not os.path.isfile(children_proc_path):
continue
with open(children_proc_path) as children_list_file_2:
children_list_pid_2 = children_list_file_2.read().split()
for _pid in children_list_pid_2:
try:
os.kill(int(_pid), signal.SIGTERM)
except ProcessLookupError:
continue
try:
os.kill(int(child_pid), signal.SIGTERM)
except ProcessLookupError:
continue

def watchdog(sleep_interval):
"""Watch project files, restart worker process if a change happened.

def kill_process_children_osx(pid):
"""Find and kill child processes of a process.

:param pid: PID of parent process (process ID)
:param sleep_interval: interval in second.
:return: Nothing
"""
subprocess.run(["pkill", "-P", str(pid)])

def interrupt_self(*args):
raise KeyboardInterrupt

def kill_process_children(pid):
"""Find and kill child processes of a process.

:param pid: PID of parent process (process ID)
:return: Nothing
"""
if sys.platform == "darwin":
kill_process_children_osx(pid)
elif sys.platform == "linux":
kill_process_children_unix(pid)
else:
pass # should signal error here
mtimes = {}
signal.signal(signal.SIGTERM, interrupt_self)
if os.name == "nt":
signal.signal(signal.SIGBREAK, interrupt_self)

worker_process = restart_with_reloader()

def kill_program_completly(proc):
"""Kill worker and it's child processes and exit.
try:
while True:
need_reload = False

:param proc: worker process (process ID)
:return: Nothing
"""
kill_process_children(proc.pid)
proc.terminate()
os._exit(0)
for filename in _iter_module_files():
try:
mtime = os.stat(filename).st_mtime
except OSError:
continue

old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
elif mtime > old_time:
mtimes[filename] = mtime
need_reload = True

def watchdog(sleep_interval):
"""Watch project files, restart worker process if a change happened.

:param sleep_interval: interval in second.
:return: Nothing
"""
mtimes = {}
worker_process = restart_with_reloader()
signal.signal(
signal.SIGTERM, lambda *args: kill_program_completly(worker_process)
)
signal.signal(
signal.SIGINT, lambda *args: kill_program_completly(worker_process)
)
while True:
for filename in _iter_module_files():
try:
mtime = os.stat(filename).st_mtime
except OSError:
continue

old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
continue
elif mtime > old_time:
kill_process_children(worker_process.pid)
if need_reload:
worker_process.terminate()
worker_process.wait()
worker_process = restart_with_reloader()
mtimes[filename] = mtime
break

sleep(sleep_interval)
sleep(sleep_interval)
except KeyboardInterrupt:
pass
finally:
worker_process.terminate()
worker_process.wait()
88 changes: 88 additions & 0 deletions tests/test_reloader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import os
import secrets
import sys

from subprocess import PIPE, Popen
from tempfile import TemporaryDirectory
from textwrap import dedent
from threading import Timer
from time import sleep

import pytest


# We need to interrupt the autoreloader without killing it, so that the server gets terminated
# https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/

try:
from signal import CTRL_BREAK_EVENT
from subprocess import CREATE_NEW_PROCESS_GROUP

flags = CREATE_NEW_PROCESS_GROUP
except ImportError:
flags = 0

def terminate(proc):
if flags:
proc.send_signal(CTRL_BREAK_EVENT)
else:
proc.terminate()

def write_app(filename, **runargs):
text = secrets.token_urlsafe()
with open(filename, "w") as f:
f.write(dedent(f"""\
import os
from sanic import Sanic

app = Sanic(__name__)

@app.listener("after_server_start")
def complete(*args):
print("complete", os.getpid(), {text!r})

if __name__ == "__main__":
app.run(**{runargs!r})
"""
))
return text

def scanner(proc):
for line in proc.stdout:
line = line.decode().strip()
print(">", line)
if line.startswith("complete"):
yield line


argv = dict(
script=[sys.executable, "reloader.py"],
module=[sys.executable, "-m", "reloader"],
sanic=[sys.executable, "-m", "sanic", "--port", "42104", "--debug", "reloader.app"],
)

@pytest.mark.parametrize("runargs, mode", [
(dict(port=42102, auto_reload=True), "script"),
(dict(port=42103, debug=True), "module"),
(dict(), "sanic"),
])
async def test_reloader_live(runargs, mode):
with TemporaryDirectory() as tmpdir:
filename = os.path.join(tmpdir, "reloader.py")
text = write_app(filename, **runargs)
proc = Popen(argv[mode], cwd=tmpdir, stdout=PIPE, creationflags=flags)
try:
timeout = Timer(5, terminate, [proc])
timeout.start()
# Python apparently keeps using the old source sometimes if
# we don't sleep before rewrite (pycache timestamp problem?)
sleep(1)
line = scanner(proc)
assert text in next(line)
# Edit source code and try again
text = write_app(filename, **runargs)
assert text in next(line)
finally:
timeout.cancel()
terminate(proc)
proc.wait(timeout=3)