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 watchdog reload worker repeatedly if there are multiple changed files #1555

Closed
wants to merge 8 commits into from
Closed
3 changes: 2 additions & 1 deletion sanic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,8 @@ def run(
auto_reload
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
):
reloader_helpers.watchdog(2)
for current_worker in reloader_helpers.watchdog(2):
pass
else:
serve(**server_settings)
else:
Expand Down
67 changes: 43 additions & 24 deletions sanic/reloader_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ def _get_args_for_reloading():
return rv


def restart_with_reloader():
def restart_with_reloader(reload_command=None):
"""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)
cmd = " ".join(args) if reload_command is None else reload_command
worker_process = Process(
target=subprocess.call,
args=(cmd,),
Expand Down Expand Up @@ -121,7 +121,7 @@ def kill_process_children(pid):
pass # should signal error here


def kill_program_completly(proc):
def kill_program_completely(proc):
"""Kill worker and it's child processes and exit.

:param proc: worker process (process ID)
Expand All @@ -132,36 +132,55 @@ def kill_program_completly(proc):
os._exit(0)


def watchdog(sleep_interval):
def poll_filesystem(mtimes):
"""Polling the file system for changed modification time.

:param mtimes: shared dictionary for tracking modification time
:return: number of changed files (integer)
"""
changes_detected = 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
changes_detected += 1

return changes_detected


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

:param sleep_interval: interval in second.
:return: Nothing
:param reload_command: command line to open for the subprocess
(None: same command line with current process)
:return Iterator[multiprocessing.Process]: Iterator of current
worker process
"""
mtimes = {}
worker_process = restart_with_reloader()
worker_process = restart_with_reloader(reload_command)
signal.signal(
signal.SIGTERM, lambda *args: kill_program_completly(worker_process)
signal.SIGTERM, lambda *args: kill_program_completely(worker_process)
)
signal.signal(
signal.SIGINT, lambda *args: kill_program_completly(worker_process)
signal.SIGINT, lambda *args: kill_program_completely(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)
worker_process.terminate()
worker_process = restart_with_reloader()
mtimes[filename] = mtime
break
poll_filesystem(mtimes) # Collect initial mtimes
yield worker_process

while True:
# There is not likely any changes initially, just sleep.
sleep(sleep_interval)
if poll_filesystem(mtimes) > 0:
kill_process_children(worker_process.pid)
worker_process.terminate()
worker_process = restart_with_reloader(reload_command)
yield worker_process
2 changes: 2 additions & 0 deletions tests/dummy_module/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import submodule01
from . import submodule02
2 changes: 2 additions & 0 deletions tests/dummy_module/submodule01.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Dummy module for testing
SUBMODULE02=True
1 change: 1 addition & 0 deletions tests/dummy_module/submodule02.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Dummy module for testing
40 changes: 40 additions & 0 deletions tests/test_poll_filesystem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import threading
import time
import os

from sanic.reloader_helpers import poll_filesystem, watchdog, kill_process_children


def test_poll_filesystem(app):
import dummy_module as dm
mtimes = {}

assert poll_filesystem(mtimes) == 0
assert poll_filesystem(mtimes) == 0
ts_now_1 = time.time()
os.utime(dm.__file__, (ts_now_1, ts_now_1))
assert poll_filesystem(mtimes) == 1
assert poll_filesystem(mtimes) == 0
time.sleep(0.1)
ts_now_2 = time.time()
os.utime(dm.__file__, (ts_now_2, ts_now_1)) # same modified time
os.utime(dm.submodule01.__file__, (ts_now_2, ts_now_2))
os.utime(dm.submodule02.__file__, (ts_now_2, ts_now_2))
assert poll_filesystem(mtimes) == 2
assert poll_filesystem(mtimes) == 0


def test_watchdog(app):
import dummy_module as dm
try:
watchdog_iter = watchdog(0.2, "python -m examples.simple_server")
worker1 = next(watchdog_iter) # Initial worker
ts_now_1 = time.time()
os.utime(dm.__file__, (ts_now_1, ts_now_1))
worker2 = next(watchdog_iter)
time.sleep(0.1)
assert worker2.pid != worker1.pid
assert not worker1.is_alive()
finally:
kill_process_children(worker2.pid)
worker2.terminate()