From f42edf259592165b3cce50748a316ef591533abe Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 17:10:46 -0400 Subject: [PATCH 01/10] Add support for being run as asgi instead of wsgi. --- fear_tracker_site/asgi.py | 12 ++++++++++++ fear_tracker_site/routing.py | 4 ++++ fear_tracker_site/settings.py | 1 + 3 files changed, 17 insertions(+) create mode 100644 fear_tracker_site/asgi.py create mode 100644 fear_tracker_site/routing.py diff --git a/fear_tracker_site/asgi.py b/fear_tracker_site/asgi.py new file mode 100644 index 0000000..f780290 --- /dev/null +++ b/fear_tracker_site/asgi.py @@ -0,0 +1,12 @@ +""" +ASGI entrypoint. Configures Django and then runs the application +defined in the ASGI_APPLICATION setting. +""" + +import os +import django +from channels.routing import get_default_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "fear_tracker_site.settings") +django.setup() +application = get_default_application() diff --git a/fear_tracker_site/routing.py b/fear_tracker_site/routing.py new file mode 100644 index 0000000..fc40f5d --- /dev/null +++ b/fear_tracker_site/routing.py @@ -0,0 +1,4 @@ +from channels.routing import ProtocolTypeRouter + +# No async routing currently; just default sync http. +application = ProtocolTypeRouter({}) diff --git a/fear_tracker_site/settings.py b/fear_tracker_site/settings.py index bdddc4c..727687a 100644 --- a/fear_tracker_site/settings.py +++ b/fear_tracker_site/settings.py @@ -69,6 +69,7 @@ TEMPLATES = [ ] WSGI_APPLICATION = 'fear_tracker_site.wsgi.application' +ASGI_APPLICATION = 'fear_tracker_site.routing.application' # Database -- 2.45.1 From 02a8ed8730382dcf98a813c6cdd74d67c7336d9a Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 18:09:15 -0400 Subject: [PATCH 02/10] Add migrations to .gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e7caab5..a6028ad 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ target/ # sqlite database *.sqlite3 +fear_tracker/migrations -- 2.45.1 From 93e521aca0c6b6b7199d6731e0f890fe4108e02a Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 18:01:50 -0700 Subject: [PATCH 03/10] Initial support for long-polling. --- fear_tracker/routing.py | 16 +++++++++++++ fear_tracker/templates/game.html | 9 ++++++-- fear_tracker/urls.py | 4 +--- fear_tracker/views.py | 39 ++++++++++++++++++++++++++++++++ fear_tracker_site/routing.py | 5 ++-- fear_tracker_site/settings.py | 7 ++++++ 6 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 fear_tracker/routing.py diff --git a/fear_tracker/routing.py b/fear_tracker/routing.py new file mode 100644 index 0000000..14a9695 --- /dev/null +++ b/fear_tracker/routing.py @@ -0,0 +1,16 @@ +from channels.http import AsgiHandler +from channels.routing import ProtocolTypeRouter, URLRouter + +from django.conf.urls import url + +from . import views + +application = ProtocolTypeRouter({ + "http": URLRouter([ + url(r'^(?P[a-zA-Z]{6})/', URLRouter([ + url(r"status/(?P[a-z0-9]{64})/", + views.StatusLongPollConsumer, name='status'), + ])), + url(r"", AsgiHandler), + ]), +}) diff --git a/fear_tracker/templates/game.html b/fear_tracker/templates/game.html index 9e35637..578e5f9 100644 --- a/fear_tracker/templates/game.html +++ b/fear_tracker/templates/game.html @@ -360,7 +360,7 @@ el.addEventListener("change", formElementChanged); } - setInterval(function() { + function checkStatus() { if(activeRequests.size != 0) return; fetch(new Request("{% url 'status' access_code=access_code %}" + (statusObj.hash != "" @@ -381,8 +381,13 @@ } else { statusObj = data; } + }) + .catch(() => {}) + .then(() => { + checkStatus(); }); - }, 5000); + } + checkStatus(); }); {% endif %} diff --git a/fear_tracker/urls.py b/fear_tracker/urls.py index 725caf1..d88d903 100644 --- a/fear_tracker/urls.py +++ b/fear_tracker/urls.py @@ -27,7 +27,5 @@ urlpatterns = [ path('update/', views.update_game, name='update_game'), path('qr/', views.qr_code, name='qr_code'), path('status/', views.status, name='status'), - url('^status/(?P[a-z0-9]{64})/', - views.status, name='status'), - ])), + ])), ] diff --git a/fear_tracker/views.py b/fear_tracker/views.py index 7a7b468..5e94a5f 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -5,6 +5,11 @@ import hashlib import json import qrcode +from asgiref.sync import async_to_sync + +from channels.generic.http import AsyncHttpConsumer +from channels.layers import get_channel_layer + from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -278,6 +283,10 @@ def handle_game_request(request, game, update): for player in players.values(): player.ready = True + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, + {"type": "fear_tracker.invalidate_status"}) + if update: if errors: res = { @@ -325,3 +334,33 @@ def status(request, game, hashcode=None): else: status_string = json.dumps(status_obj) return HttpResponse(status_string) + + +class StatusLongPollConsumer(AsyncHttpConsumer): + async def handle(self, body): + self.access_code = self.scope["url_route"]["kwargs"]["access_code"] + self.hashcode = self.scope["url_route"]["kwargs"]["hashcode"] + + await self.channel_layer.group_add("%s_status" % self.access_code, + self.channel_name) + + async def http_request(self, message): + """ + Async entrypoint - concatenates body fragments and hands off control + to ``self.handle`` when the body has been completely received. + """ + if "body" in message: + self.body.append(message["body"]) + if not message.get("more_body"): + await self.handle(b"".join(self.body)) + + async def disconnect(self): + await self.channel_layer.group_discard("%s_status" % self.access_code, + self.channel_name) + + async def fear_tracker_invalidate_status(self, event): + no_hash_status = reverse('status', + kwargs={'access_code': self.access_code}) + await self.send_response(302, b'', headers=[ + (b"Location", no_hash_status.encode('utf-8')) + ]) diff --git a/fear_tracker_site/routing.py b/fear_tracker_site/routing.py index fc40f5d..8528954 100644 --- a/fear_tracker_site/routing.py +++ b/fear_tracker_site/routing.py @@ -1,4 +1,3 @@ -from channels.routing import ProtocolTypeRouter +import fear_tracker.routing -# No async routing currently; just default sync http. -application = ProtocolTypeRouter({}) +application = fear_tracker.routing.application diff --git a/fear_tracker_site/settings.py b/fear_tracker_site/settings.py index 727687a..1aa6d14 100644 --- a/fear_tracker_site/settings.py +++ b/fear_tracker_site/settings.py @@ -31,6 +31,7 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ + 'channels', 'fear_tracker', 'django.contrib.admin', 'django.contrib.auth', @@ -82,6 +83,12 @@ DATABASES = { } } +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer" + } +} + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators -- 2.45.1 From 564d79e8316d0f81659c9d2a4d6276b9a045c68d Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 18:09:09 -0700 Subject: [PATCH 04/10] Add some recovery attempts for race condition of missing status invalidation. --- fear_tracker/views.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/fear_tracker/views.py b/fear_tracker/views.py index 5e94a5f..8e2c1cb 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -158,6 +158,12 @@ def game_status_object(game, current_phase=None, players_with_fear=None): status_hash = h.hexdigest() status_obj['hash'] = status_hash + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": status_hash, + }) + return status_obj @@ -343,6 +349,11 @@ class StatusLongPollConsumer(AsyncHttpConsumer): await self.channel_layer.group_add("%s_status" % self.access_code, self.channel_name) + await self.channel_layer.group_send( + "%s_status" % self.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": self.hashcode, + }) async def http_request(self, message): """ @@ -358,6 +369,13 @@ class StatusLongPollConsumer(AsyncHttpConsumer): await self.channel_layer.group_discard("%s_status" % self.access_code, self.channel_name) + async def fear_tracker_hashcode_seen(self, event): + if self.hashcode != event["hashcode"]: + await self.channel_layer.group_send( + "%s_status" % self.access_code, { + "type": "fear_tracker.invalidate_status", + }) + async def fear_tracker_invalidate_status(self, event): no_hash_status = reverse('status', kwargs={'access_code': self.access_code}) -- 2.45.1 From 8aba4f852979f35c23e690af1a941aeb48f8b7d4 Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 18:25:22 -0700 Subject: [PATCH 05/10] Disconnect when done with status consumer. --- fear_tracker/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fear_tracker/views.py b/fear_tracker/views.py index 8e2c1cb..419579c 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -382,3 +382,4 @@ class StatusLongPollConsumer(AsyncHttpConsumer): await self.send_response(302, b'', headers=[ (b"Location", no_hash_status.encode('utf-8')) ]) + await self.http_disconnect(None) -- 2.45.1 From 997d1260c8c9d30d0f8dc5f3deffe0b560b5ea86 Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 19:29:32 -0700 Subject: [PATCH 06/10] Add script_prefix support. --- fear_tracker/views.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/fear_tracker/views.py b/fear_tracker/views.py index 419579c..d07f14a 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -10,12 +10,13 @@ from asgiref.sync import async_to_sync from channels.generic.http import AsyncHttpConsumer from channels.layers import get_channel_layer +from django.conf import settings from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_safe, require_http_methods,\ require_POST -from django.urls import reverse +from django.urls import reverse, set_script_prefix from .forms import NewGameForm, JoinGameForm, PlayerFormSet from .models import Game, Player, Fear @@ -377,6 +378,12 @@ class StatusLongPollConsumer(AsyncHttpConsumer): }) async def fear_tracker_invalidate_status(self, event): + # get script_prefix for reverse() + script_prefix = self.scope.get("root_path", "") or "" + if settings.FORCE_SCRIPT_NAME: + script_prefix = settings.FORCE_SCRIPT_NAME + set_script_prefix(script_prefix) + no_hash_status = reverse('status', kwargs={'access_code': self.access_code}) await self.send_response(302, b'', headers=[ -- 2.45.1 From 44402c390700e94b68b82fa42891a9de2f93dc55 Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 20:21:09 -0700 Subject: [PATCH 07/10] Cancel long-polling for status from client side after 50 seconds. --- fear_tracker/templates/game.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fear_tracker/templates/game.html b/fear_tracker/templates/game.html index 578e5f9..20078e8 100644 --- a/fear_tracker/templates/game.html +++ b/fear_tracker/templates/game.html @@ -362,11 +362,21 @@ function checkStatus() { if(activeRequests.size != 0) return; + // From https://stackoverflow.com/a/50101022 + + const abort = new AbortController(); + const signal = abort.signal; + + // 50 second timeout: + const timeoutId = setTimeout(() => abort.abort(), 50000); + fetch(new Request("{% url 'status' access_code=access_code %}" + (statusObj.hash != "" ? statusObj.hash + "/" - : ""))) + : "")), + {signal}) .then(response => { + clearTimeout(timeoutId); if(response.status === 304) { // TODO Just skip the next step? return statusObj; -- 2.45.1 From 9411b21d0f7470c0a73f2abf4561e8e925934f8a Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 20:41:58 -0700 Subject: [PATCH 08/10] Wait to retry on error. --- fear_tracker/templates/game.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/fear_tracker/templates/game.html b/fear_tracker/templates/game.html index 20078e8..a05388f 100644 --- a/fear_tracker/templates/game.html +++ b/fear_tracker/templates/game.html @@ -392,9 +392,10 @@ statusObj = data; } }) - .catch(() => {}) - .then(() => { - checkStatus(); + .then(checkStatus) + .catch(() => { + // If something went wrong, wait a few seconds before retrying. + setTimeout(checkStatus, 5000); }); } checkStatus(); -- 2.45.1 From 96c41150f975c7fe654646b03b1575ff0309a8fc Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 20:42:34 -0700 Subject: [PATCH 09/10] Push new status immediately if possible instead of invalidating and forcing a reload. --- fear_tracker/views.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/fear_tracker/views.py b/fear_tracker/views.py index d07f14a..118402d 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -159,12 +159,6 @@ def game_status_object(game, current_phase=None, players_with_fear=None): status_hash = h.hexdigest() status_obj['hash'] = status_hash - async_to_sync(get_channel_layer().group_send)( - "%s_status" % game.access_code, { - "type": "fear_tracker.hashcode_seen", - "hashcode": status_hash, - }) - return status_obj @@ -290,10 +284,6 @@ def handle_game_request(request, game, update): for player in players.values(): player.ready = True - async_to_sync(get_channel_layer().group_send)( - "%s_status" % game.access_code, - {"type": "fear_tracker.invalidate_status"}) - if update: if errors: res = { @@ -304,12 +294,22 @@ def handle_game_request(request, game, update): res['value'] = current_value else: res = {'success': True} + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, + {"type": "fear_tracker.invalidate_status"}) return HttpResponse(json.dumps(res)) players = get_players_with_fear(game, current_phase, players) status_obj = game_status_object(game, current_phase, players) status_string = json.dumps(status_obj) + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": status_obj['hash'], + "status_string": status_string, + }) + for player in players.values(): info = player.fear if not info: @@ -340,6 +340,12 @@ def status(request, game, hashcode=None): return HttpResponse(status=HTTPStatus.NOT_MODIFIED) else: status_string = json.dumps(status_obj) + async_to_sync(get_channel_layer().group_send)( + "%s_status" % game.access_code, { + "type": "fear_tracker.hashcode_seen", + "hashcode": status_obj['hash'], + "status_string": status_string, + }) return HttpResponse(status_string) @@ -372,6 +378,10 @@ class StatusLongPollConsumer(AsyncHttpConsumer): async def fear_tracker_hashcode_seen(self, event): if self.hashcode != event["hashcode"]: + if event["status_string"]: + body = event["status_string"].encode('utf-8') + await self.send_response(200, body) + await self.disconnect() await self.channel_layer.group_send( "%s_status" % self.access_code, { "type": "fear_tracker.invalidate_status", -- 2.45.1 From 1c442bec41ca5c07dcf9a7f9a1599ef805568ee9 Mon Sep 17 00:00:00 2001 From: Daniel Perelman Date: Sat, 11 Apr 2020 23:19:21 -0700 Subject: [PATCH 10/10] Let nginx proxy_redirect handle subdirectory for redirects. --- fear_tracker/views.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/fear_tracker/views.py b/fear_tracker/views.py index 118402d..3af103b 100644 --- a/fear_tracker/views.py +++ b/fear_tracker/views.py @@ -10,13 +10,12 @@ from asgiref.sync import async_to_sync from channels.generic.http import AsyncHttpConsumer from channels.layers import get_channel_layer -from django.conf import settings from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_safe, require_http_methods,\ require_POST -from django.urls import reverse, set_script_prefix +from django.urls import reverse from .forms import NewGameForm, JoinGameForm, PlayerFormSet from .models import Game, Player, Fear @@ -388,12 +387,6 @@ class StatusLongPollConsumer(AsyncHttpConsumer): }) async def fear_tracker_invalidate_status(self, event): - # get script_prefix for reverse() - script_prefix = self.scope.get("root_path", "") or "" - if settings.FORCE_SCRIPT_NAME: - script_prefix = settings.FORCE_SCRIPT_NAME - set_script_prefix(script_prefix) - no_hash_status = reverse('status', kwargs={'access_code': self.access_code}) await self.send_response(302, b'', headers=[ -- 2.45.1