Skip to content

Commit 04eac2a

Browse files
Improve reliability of reload (#631)
1 parent e3a340f commit 04eac2a

File tree

4 files changed

+63
-7
lines changed

4 files changed

+63
-7
lines changed

aiohttp_devtools/runserver/serve.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import json
44
import mimetypes
55
import sys
6+
import time
67
import warnings
78
from errno import EADDRINUSE
89
from pathlib import Path
9-
from typing import Any, Iterator, NoReturn, Optional, Set, Tuple
10+
from typing import Any, Iterator, List, NoReturn, Optional, Set, Tuple
1011

1112
from aiohttp import WSMsgType, web
1213
from aiohttp.hdrs import LAST_MODIFIED, CONTENT_LENGTH
@@ -33,6 +34,7 @@
3334
LIVE_RELOAD_LOCAL_SNIPPET = b'\n<script src="/livereload.js"></script>\n'
3435
HOST = '0.0.0.0'
3536

37+
LAST_RELOAD = web.AppKey("LAST_RELOAD", List[float])
3638
LIVERELOAD_SCRIPT = web.AppKey("LIVERELOAD_SCRIPT", bytes)
3739
STATIC_PATH = web.AppKey("STATIC_PATH", str)
3840
STATIC_URL = web.AppKey("STATIC_URL", str)
@@ -240,6 +242,8 @@ async def src_reload(app: web.Application, path: Optional[str] = None) -> int:
240242
else:
241243
reloads += 1
242244

245+
app[LAST_RELOAD][0] = len(app[WS])
246+
app[LAST_RELOAD][1] = time.time()
243247
if reloads:
244248
s = '' if reloads == 1 else 's'
245249
aux_logger.info('prompted reload of %s on %d client%s', path or 'page', reloads, s)
@@ -256,6 +260,7 @@ def create_auxiliary_app(
256260
browser_cache: bool = False) -> web.Application:
257261
app = web.Application()
258262
ws: Set[Tuple[web.WebSocketResponse, str]] = set()
263+
app[LAST_RELOAD] = [0, 0.]
259264
app[STATIC_PATH] = static_path or ""
260265
app[STATIC_URL] = static_url
261266
app[WS] = ws

aiohttp_devtools/runserver/watch.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import signal
44
import sys
5+
import time
56
from contextlib import suppress
67
from multiprocessing import Process
78
from pathlib import Path
@@ -14,7 +15,7 @@
1415
from ..exceptions import AiohttpDevException
1516
from ..logs import rs_dft_logger as logger
1617
from .config import Config
17-
from .serve import STATIC_PATH, WS, serve_main_app, src_reload
18+
from .serve import LAST_RELOAD, STATIC_PATH, WS, serve_main_app, src_reload
1819

1920

2021
class WatchTask:
@@ -27,7 +28,7 @@ def __init__(self, path: Union[Path, str]):
2728
async def start(self, app: web.Application) -> None:
2829
self._app = app
2930
self.stopper = asyncio.Event()
30-
self._awatch = awatch(self._path, stop_event=self.stopper)
31+
self._awatch = awatch(self._path, stop_event=self.stopper, step=250)
3132
self._task = asyncio.create_task(self._run())
3233

3334
async def _run(self) -> None:
@@ -71,8 +72,20 @@ def is_static(changes: Iterable[Tuple[object, str]]) -> bool:
7172

7273
async for changes in self._awatch:
7374
self._reloads += 1
75+
logger.debug("file changes: %s", changes)
7476
if any(f.endswith('.py') for _, f in changes):
7577
logger.debug('%d changes, restarting server', len(changes))
78+
79+
count, t = self._app[LAST_RELOAD]
80+
if len(self._app[WS]) < count:
81+
wait_delay = max(t + 5 - time.time(), 0)
82+
logger.debug("waiting upto %s seconds before restarting", wait_delay)
83+
84+
for i in range(int(wait_delay / 0.1)):
85+
await asyncio.sleep(0.1)
86+
if len(self._app[WS]) >= count:
87+
break
88+
7689
await self._stop_dev_server()
7790
self._start_dev_server()
7891
await self._src_reload_when_live(live_checks)

tests/test_runserver_serve.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from aiohttp_devtools.runserver.config import Config
1414
from aiohttp_devtools.runserver.log_handlers import fmt_size
1515
from aiohttp_devtools.runserver.serve import (
16-
STATIC_PATH, STATIC_URL, WS, check_port_open, cleanup_aux_app,
16+
LAST_RELOAD, STATIC_PATH, STATIC_URL, WS, check_port_open, cleanup_aux_app,
1717
modify_main_app, src_reload)
1818

1919
from .conftest import SIMPLE_APP, create_future
@@ -36,6 +36,7 @@ async def test_aux_reload(smart_caplog):
3636
aux_app = Application()
3737
ws = MagicMock()
3838
ws.send_str = MagicMock(return_value=create_future())
39+
aux_app[LAST_RELOAD] = [0, 0.]
3940
aux_app[STATIC_PATH] = "/path/to/static_files/"
4041
aux_app[STATIC_URL] = "/static/"
4142
aux_app[WS] = set(((ws, "/foo/bar"),)) # type: ignore[misc]
@@ -56,6 +57,7 @@ async def test_aux_reload_no_path():
5657
aux_app = Application()
5758
ws = MagicMock()
5859
ws.send_str = MagicMock(return_value=create_future())
60+
aux_app[LAST_RELOAD] = [0, 0.]
5961
aux_app[STATIC_PATH] = "/path/to/static_files/"
6062
aux_app[STATIC_URL] = "/static/"
6163
aux_app[WS] = set(((ws, "/foo/bar"),)) # type: ignore[misc]
@@ -74,6 +76,7 @@ async def test_aux_reload_html_different():
7476
aux_app = Application()
7577
ws = MagicMock()
7678
ws.send_str = MagicMock(return_value=create_future())
79+
aux_app[LAST_RELOAD] = [0, 0.]
7780
aux_app[STATIC_PATH] = "/path/to/static_files/"
7881
aux_app[STATIC_URL] = "/static/"
7982
aux_app[WS] = set(((ws, "/foo/bar"),)) # type: ignore[misc]
@@ -86,6 +89,7 @@ async def test_aux_reload_runtime_error(smart_caplog):
8689
ws = MagicMock()
8790
ws.send_str = MagicMock(return_value=create_future())
8891
ws.send_str = MagicMock(side_effect=RuntimeError('foobar'))
92+
aux_app[LAST_RELOAD] = [0, 0.]
8993
aux_app[STATIC_PATH] = "/path/to/static_files/"
9094
aux_app[STATIC_URL] = "/static/"
9195
aux_app[WS] = set(((ws, "/foo/bar"),)) # type: ignore[misc]

tests/test_runserver_watch.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import asyncio
2+
import time
23
from functools import partial
3-
from typing import Set, Tuple
4-
from unittest.mock import MagicMock, call
4+
from typing import Any, Set, Tuple
5+
from unittest.mock import AsyncMock, MagicMock, call
56

67
from aiohttp import ClientSession
78
from aiohttp.web import Application, WebSocketResponse
89

9-
from aiohttp_devtools.runserver.serve import STATIC_PATH, WS
10+
from aiohttp_devtools.runserver.serve import LAST_RELOAD, STATIC_PATH, WS
1011
from aiohttp_devtools.runserver.watch import AppTask, LiveReloadTask
1112

1213
from .conftest import create_future
@@ -81,6 +82,7 @@ async def test_python_no_server(event_loop, mocker):
8182
stop_mock = mocker.patch.object(app_task, "_stop_dev_server", autospec=True)
8283
mocker.patch.object(app_task, "_run", partial(app_task._run, live_checks=2))
8384
app = Application()
85+
app[LAST_RELOAD] = [0, 0.]
8486
app[STATIC_PATH] = "/path/to/"
8587
app.src_reload = MagicMock()
8688
mock_ws = MagicMock()
@@ -192,3 +194,35 @@ async def test_stop_process_dirty(mocker):
192194
await app_task._stop_dev_server()
193195
assert mock_kill.call_args_list == [call(321, 2)]
194196
assert process_mock.kill.called_once()
197+
198+
199+
async def test_restart_after_connection_loss(mocker):
200+
mocked_awatch = mocker.patch("aiohttp_devtools.runserver.watch.awatch", autospec=True, spec_set=True)
201+
mocked_awatch.side_effect = create_awatch_mock({("x", "/path/to/file.py")})
202+
app_task = AppTask(MagicMock())
203+
start_mock = mocker.patch.object(app_task, "_start_dev_server", autospec=True, spec_set=True)
204+
mock_reload = mocker.patch.object(app_task, "_src_reload_when_live", autospec=True, spec_set=True)
205+
mocker.patch.object(app_task, "_stop_dev_server", autospec=True, spec_set=True)
206+
207+
app = mocker.create_autospec(Application, spec_set=True, instance=True)
208+
# Simulate connection lost from recent restart.
209+
ws: Set[Any] = set()
210+
d = {WS: ws, LAST_RELOAD: [1, time.time()]}
211+
app.__getitem__.side_effect = lambda k: d.get(k, MagicMock())
212+
213+
def update_ws(i):
214+
ws.add(MagicMock(spec_set=()))
215+
return AsyncMock()
216+
217+
sleep_mock = mocker.patch("asyncio.sleep", autospec=True, spec_set=True)
218+
sleep_mock.side_effect = update_ws
219+
220+
await app_task.start(app)
221+
assert app_task._task is not None
222+
await app_task._task
223+
assert sleep_mock.call_count < 5
224+
assert call(0.1) in sleep_mock.call_args_list
225+
mock_reload.assert_called_once()
226+
assert start_mock.call_count == 2
227+
assert app_task._session is not None
228+
await app_task._session.close()

0 commit comments

Comments
 (0)