Allow stopping update task

For situations where the user launches a distro comparison update
process and then shortly afterwards realises it is operating with the
wrong configuration (or is otherwise broken) and is going to take a long
time to finish, add a button to the task page to stop the task. This was
tricky to get working, since the default behaviour of Celery's revoke()
would either terminate both the Celery task process along with the update
process (leaving us with no log saved to the database) or worse not even
kill the update process, depending on the signal sent. To avoid this,
send SIGUSR2, trap it in the task process and kill the child process,
returning gracefully. To make that possible I had to rewrite runcmd() to
use subprocess.Popen() instead of subprocess.check_call() as otherwise
we can't get the child's PID.

Signed-off-by: Paul Eggleton <paul.eggleton@linux.intel.com>
This commit is contained in:
Paul Eggleton 2018-09-18 17:31:57 +12:00
parent ac73780bd9
commit d84bfd710d
4 changed files with 39 additions and 4 deletions

View File

@ -8,7 +8,7 @@ from django.conf.urls import *
from django.views.generic import TemplateView, DetailView, ListView, RedirectView from django.views.generic import TemplateView, DetailView, ListView, RedirectView
from django.views.defaults import page_not_found from django.views.defaults import page_not_found
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from layerindex.views import LayerListView, LayerReviewListView, LayerReviewDetailView, RecipeSearchView, MachineSearchView, LayerDetailView, edit_layer_view, delete_layer_view, edit_layernote_view, delete_layernote_view, HistoryListView, EditProfileFormView, AdvancedRecipeSearchView, BulkChangeView, BulkChangeSearchView, bulk_change_edit_view, bulk_change_patch_view, BulkChangeDeleteView, RecipeDetailView, RedirectParamsView, ClassicRecipeSearchView, ClassicRecipeDetailView, ClassicRecipeStatsView, LayerUpdateDetailView, UpdateListView, UpdateDetailView, StatsView, publish_view, LayerCheckListView, BBClassCheckListView, TaskStatusView, ComparisonRecipeSelectView, ComparisonRecipeSelectDetailView, task_log_view from layerindex.views import LayerListView, LayerReviewListView, LayerReviewDetailView, RecipeSearchView, MachineSearchView, LayerDetailView, edit_layer_view, delete_layer_view, edit_layernote_view, delete_layernote_view, HistoryListView, EditProfileFormView, AdvancedRecipeSearchView, BulkChangeView, BulkChangeSearchView, bulk_change_edit_view, bulk_change_patch_view, BulkChangeDeleteView, RecipeDetailView, RedirectParamsView, ClassicRecipeSearchView, ClassicRecipeDetailView, ClassicRecipeStatsView, LayerUpdateDetailView, UpdateListView, UpdateDetailView, StatsView, publish_view, LayerCheckListView, BBClassCheckListView, TaskStatusView, ComparisonRecipeSelectView, ComparisonRecipeSelectDetailView, task_log_view, task_stop_view
from layerindex.models import LayerItem, Recipe, RecipeChangeset from layerindex.models import LayerItem, Recipe, RecipeChangeset
from rest_framework import routers from rest_framework import routers
from . import restviews from . import restviews
@ -171,6 +171,9 @@ urlpatterns = [
url(r'^tasklog/(?P<task_id>[-\w]+)/$', url(r'^tasklog/(?P<task_id>[-\w]+)/$',
task_log_view, task_log_view,
name='task_log'), name='task_log'),
url(r'^stoptask/(?P<task_id>[-\w]+)/$',
task_stop_view,
name='task_stop'),
url(r'^ajax/layerchecklist/(?P<branch>[-\w]+)/$', url(r'^ajax/layerchecklist/(?P<branch>[-\w]+)/$',
LayerCheckListView.as_view( LayerCheckListView.as_view(
template_name='layerindex/layerchecklist.html'), template_name='layerindex/layerchecklist.html'),

View File

@ -285,6 +285,7 @@ def parse_layer_conf(layerdir, data, logger=None):
data = parse_conf(conf_file, data) data = parse_conf(conf_file, data)
data.expandVarref('LAYERDIR') data.expandVarref('LAYERDIR')
child_pid = 0
def runcmd(cmd, destdir=None, printerr=True, outfile=None, logger=None): def runcmd(cmd, destdir=None, printerr=True, outfile=None, logger=None):
""" """
execute command, raise CalledProcessError if fail execute command, raise CalledProcessError if fail
@ -296,10 +297,17 @@ def runcmd(cmd, destdir=None, printerr=True, outfile=None, logger=None):
out = open(outfile, 'wb+') out = open(outfile, 'wb+')
else: else:
out = tempfile.TemporaryFile() out = tempfile.TemporaryFile()
def onsigusr2(sig, frame):
# Kill the child process
os.kill(child_pid, signal.SIGTERM)
signal.signal(signal.SIGUSR2, onsigusr2)
try: try:
try: proc = subprocess.Popen(cmd, stdout=out, stderr=out, cwd=destdir, shell=True)
subprocess.check_call(cmd, stdout=out, stderr=out, cwd=destdir, shell=True) global child_pid
except subprocess.CalledProcessError as e: child_pid = proc.pid
proc.communicate()
if proc.returncode:
out.seek(0) out.seek(0)
output = out.read() output = out.read()
output = output.decode('utf-8', errors='replace').strip() output = output.decode('utf-8', errors='replace').strip()
@ -308,6 +316,7 @@ def runcmd(cmd, destdir=None, printerr=True, outfile=None, logger=None):
logger.error("%s" % output) logger.error("%s" % output)
else: else:
sys.stderr.write("%s\n" % output) sys.stderr.write("%s\n" % output)
e = subprocess.CalledProcessError(proc.returncode, cmd)
e.output = output e.output = output
raise e raise e
@ -317,6 +326,7 @@ def runcmd(cmd, destdir=None, printerr=True, outfile=None, logger=None):
if logger: if logger:
logger.debug("output: %s" % output.rstrip() ) logger.debug("output: %s" % output.rstrip() )
finally: finally:
signal.signal(signal.SIGUSR2, signal.SIG_DFL)
if outfile: if outfile:
out.close() out.close()
return output return output

View File

@ -1412,6 +1412,17 @@ def task_log_view(request, task_id):
response['Task-Progress'] = preader.read() response['Task-Progress'] = preader.read()
return response return response
def task_stop_view(request, task_id):
from celery.result import AsyncResult
import signal
if not request.user.is_authenticated():
raise PermissionDenied
result = AsyncResult(task_id)
result.revoke(terminate=True, signal=signal.SIGUSR2)
return HttpResponse('terminated')
class ComparisonRecipeSelectView(ClassicRecipeSearchView): class ComparisonRecipeSelectView(ClassicRecipeSearchView):
def _can_edit(self): def _can_edit(self):
if self.request.user.is_authenticated(): if self.request.user.is_authenticated():

View File

@ -30,6 +30,10 @@
</div> </div>
{% endif %} {% endif %}
{% if not update.finished %}
<button id="stopbutton" class="btn btn-danger pull-right">Stop</a>
{% endif %}
{% if update.comparisonrecipeupdate_set.exists %} {% if update.comparisonrecipeupdate_set.exists %}
<h3>Updated comparison recipes</h3> <h3>Updated comparison recipes</h3>
<ul> <ul>
@ -92,6 +96,7 @@
}).always(function () { }).always(function () {
if(done == '1') { if(done == '1') {
$("#task_status_fragment").html(" (finished in " + duration + ")") $("#task_status_fragment").html(" (finished in " + duration + ")")
$("#stopbutton").hide()
} }
else { else {
window.setTimeout(updateLog, 1000); window.setTimeout(updateLog, 1000);
@ -103,6 +108,12 @@
scrolling = ($(this).scrollTop() + $(this).height() + 50 > $(this).prop('scrollHeight')) scrolling = ($(this).scrollTop() + $(this).height() + 50 > $(this).prop('scrollHeight'))
}); });
$("#stopbutton").click(function() {
$.ajax({
url: "{% url 'task_stop' update.task_id %}"
});
});
$(document).ready(function() { $(document).ready(function() {
{% if not update.finished %} {% if not update.finished %}
updateLog(); updateLog();