Merge pull request #24 from openHPI/webpython-hybrid

Webpython hybrid
This commit is contained in:
jprberlin
2015-10-16 10:05:19 +02:00
24 changed files with 5306 additions and 95 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@
/tmp
/vagrant/
*.sublime-*
/.idea

View File

@ -33,6 +33,8 @@ gem 'thread_safe'
gem 'turbolinks'
gem 'uglifier', '>= 1.3.0'
gem 'will_paginate', '~> 3.0'
gem 'tubesock'
gem 'faye-websocket'
group :development do
gem 'better_errors', platform: :ruby

View File

@ -50,8 +50,6 @@ GEM
bootstrap-will_paginate (0.0.10)
will_paginate
builder (3.2.2)
byebug (4.0.5)
columnize (= 0.9.0)
capistrano (3.3.5)
capistrano-stats (~> 1.1.0)
i18n
@ -96,7 +94,6 @@ GEM
execjs
coffee-script-source (1.9.1)
colorize (0.7.7)
columnize (0.9.0)
concurrent-ruby (0.8.0)
ref (~> 1.0, >= 1.0.5)
concurrent-ruby (0.8.0-java)
@ -110,6 +107,7 @@ GEM
excon (>= 0.38.0)
json
erubis (2.7.0)
eventmachine (1.0.8)
excon (0.45.2)
execjs (2.5.2)
factory_girl (4.5.0)
@ -119,6 +117,9 @@ GEM
railties (>= 3.0.0)
faraday (0.9.1)
multipart-post (>= 1.2, < 3)
faye-websocket (0.10.0)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
ffi (1.9.8)
ffi (1.9.8-java)
forgery (0.6.0)
@ -302,6 +303,9 @@ GEM
thread_safe (0.3.5)
thread_safe (0.3.5-java)
tilt (1.4.1)
tubesock (0.2.5)
rack (>= 1.5.0)
websocket (>= 1.1.0)
turbolinks (2.5.3)
coffee-rails
tzinfo (1.2.2)
@ -315,6 +319,9 @@ GEM
railties (>= 4.0)
sprockets-rails (>= 2.0, < 4.0)
websocket (1.2.1)
websocket-driver (0.6.2)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.2)
will_paginate (3.0.7)
xpath (2.0.0)
nokogiri (~> 1.3)
@ -330,7 +337,6 @@ DEPENDENCIES
better_errors
binding_of_caller
bootstrap-will_paginate
byebug
capistrano (~> 3.3.0)
capistrano-rails
capistrano-rvm
@ -345,6 +351,7 @@ DEPENDENCIES
database_cleaner
docker-api (~> 1.21.1)
factory_girl_rails (~> 4.0)
faye-websocket
forgery
highline
ims-lti
@ -375,6 +382,7 @@ DEPENDENCIES
sorcery
spring
thread_safe
tubesock
turbolinks
uglifier (>= 1.3.0)
web-console (~> 2.0)

View File

@ -11,6 +11,7 @@ $(function() {
var FILENAME_URL_PLACEHOLDER = '{filename}';
var SUCCESSFULL_PERCENTAGE = 90;
var THEME = 'ace/theme/textmate';
var REMEMBER_TAB = false;
var AUTOSAVE_INTERVAL = 15 * 1000;
var editors = [];
@ -20,6 +21,16 @@ $(function() {
var qa_api = undefined;
var output_mode_is_streaming = true;
var websocket,
turtlescreen,
numMessages = 0,
turtlecanvas = $('#turtlecanvas'),
prompt = $('#prompt'),
commands = ['input', 'write', 'turtle', 'turtlebatch', 'exit', 'status'],
streams = ['stdin', 'stdout', 'stderr'];
var ENTER_KEY_CODE = 13;
var flowrResultHtml = '<div class="panel panel-default"><div id="{{headingId}}" role="tab" class="panel-heading"><h4 class="panel-title"><a data-toggle="collapse" data-parent="#flowrHint" href="#{{collapseId}}" aria-expanded="true" aria-controls="{{collapseId}}"></a></h4></div><div id="{{collapseId}}" role="tabpanel" aria-labelledby="{{headingId}}" class="panel-collapse collapse"><div class="panel-body"></div></div></div>'
var ajax = function(options) {
@ -49,7 +60,7 @@ $(function() {
if (event.type === 'error' || JSON.parse(event.data).code !== 200) {
ajaxError();
showTab(1);
showTab(0);
}
};
@ -181,14 +192,15 @@ $(function() {
};
var evaluateCodeWithStreamedResponse = function(url, callback) {
var event_source = new EventSource(url);
initWebsocketConnection(url);
event_source.addEventListener('close', closeEventSource);
event_source.addEventListener('error', closeEventSource);
// TODO only init turtle when required
initTurtle();
// TODO reimplement via websocket messsages
/*var event_source = new EventSource(url);
event_source.addEventListener('hint', renderHint);
event_source.addEventListener('info', storeContainerInformation);
event_source.addEventListener('output', callback);
event_source.addEventListener('start', callback);
if ($('#flowrHint').isPresent()) {
event_source.addEventListener('output', handleStderrOutputForFlowr);
@ -197,11 +209,7 @@ $(function() {
if (qa_api) {
event_source.addEventListener('close', handleStreamedResponseForCodePilot);
}
event_source.addEventListener('status', function(event) {
showStatus(JSON.parse(event.data));
});
}*/
};
var handleStreamedResponseForCodePilot = function(event) {
@ -255,13 +263,11 @@ $(function() {
var handleKeyPress = function(event) {
if (event.which === ALT_1_KEY_CODE) {
showTab(0);
} else if (event.which === ALT_2_KEY_CODE) {
showWorkspaceTab(event);
} else if (event.which === ALT_2_KEY_CODE) {
showTab(1);
} else if (event.which === ALT_3_KEY_CODE) {
showTab(2);
} else if (event.which === ALT_4_KEY_CODE) {
showTab(3);
} else if (event.which === ALT_R_KEY_CODE) {
$('#run').trigger('click');
} else if (event.which === ALT_S_KEY_CODE) {
@ -304,7 +310,7 @@ $(function() {
}, 0).toFixed(2);
$('#score').data('score', score);
renderScore();
showTab(3);
showTab(2);
};
var stderrOutput = '';
@ -357,7 +363,7 @@ $(function() {
qa_api.executeCommand('syncOutput', [response]);
}
showStatus(response[0]);
showTab(2);
showTab(1);
};
var hideSpinner = function() {
@ -428,7 +434,7 @@ $(function() {
handleSidebarClick(e);
});
*/
//session
session.on('annotationRemoval', handleAnnotationRemoval);
session.on('annotationChange', handleAnnotationChange);
@ -631,11 +637,11 @@ $(function() {
};
var initializeWorkspaceButtons = function() {
$('#assess').on('click', scoreCode);
$('#assess').on('click', scoreCode); // todo
$('#dropdown-render, #render').on('click', renderCode);
$('#dropdown-run, #run').on('click', runCode);
$('#dropdown-stop, #stop').on('click', stopCode);
$('#dropdown-test, #test').on('click', testCode);
$('#dropdown-stop, #stop').on('click', stopCode); // todo
$('#dropdown-test, #test').on('click', testCode); // todo
$('#save').on('click', saveCode);
$('#start-over').on('click', confirmReset);
};
@ -676,6 +682,7 @@ $(function() {
};
var isBrowserSupported = function() {
// todo event streams are no longer required with websockets
return window.EventSource !== undefined;
};
@ -703,12 +710,16 @@ $(function() {
chunkBuffer.push(output);
}
} else {
resetOutputTab();
}
};
var resetOutputTab = function() {
clearOutput();
$('#hint').fadeOut();
$('#flowrHint').fadeOut();
showTab(2);
}
};
showTab(1);
}
var printOutput = function(output, colorize, index) {
var element = findOrCreateOutputElement(index);
@ -808,7 +819,7 @@ $(function() {
stderr: message
}, true, 0);
sendError(message, response.id);
showTab(2);
showTab(1);
};
}
});
@ -931,16 +942,21 @@ $(function() {
var showOutput = function(event) {
event.preventDefault();
showTab(2);
showTab(1);
$('#output').scrollTo($(this).attr('href'));
};
var showRequestedTab = function() {
var regexp = /tab=(\d+)/;
if (regexp.test(window.location.search)) {
var index = regexp.exec(window.location.search)[1] - 1;
if(REMEMBER_TAB){
var regexp = /tab=(\d+)/;
if (regexp.test(window.location.search)) {
var index = regexp.exec(window.location.search)[1] - 1;
} else {
var index = localStorage.tab;
}
} else {
var index = localStorage.tab;
// else start with first tab.
var index = 0;
}
showTab(index);
};
@ -954,7 +970,7 @@ $(function() {
if (output.status === 'timeout') {
showTimeoutMessage();
} else if (output.status === 'container_depleted') {
showContainerDepletedMessage();
showContainerDepletedMessage();
} else if (output.stderr) {
$.flash.danger({
icon: ['fa', 'fa-bug'],
@ -988,29 +1004,46 @@ $(function() {
});
};
var showWebsocketError = function() {
$.flash.danger({
text: $('#flash').data('message-failure')
});
}
var showWorkspaceTab = function(event) {
event.preventDefault();
showTab(1);
showTab(0);
};
var stopCode = function(event) {
event.preventDefault();
if ($('#stop').is(':visible')) {
var jqxhr = ajax({
data: {
container_id: $('#stop').data('container').id
},
url: $('#stop').data('url')
});
jqxhr.always(function() {
hideSpinner();
running = false;
toggleButtonStates();
});
jqxhr.fail(ajaxError);
killWebsocketAndContainer();
}
};
var killWebsocketAndContainer = function() {
if (websocket.readyState != WebSocket.OPEN) {
return;
}
websocket.send(JSON.stringify({cmd: 'exit'}));
websocket.flush();
websocket.close();
hideSpinner();
running = false;
toggleButtonStates();
hidePrompt();
flashKillMessage();
}
var flashKillMessage = function() {
$.flash.info({
icon: ['fa', 'fa-clock-o'],
text: "Your program was stopped." // todo get data attribute
});
}
// todo set this from websocket command, required to e.g. stop container
var storeContainerInformation = function(event) {
var container_information = JSON.parse(event.data);
$('#stop').data('container', container_information);
@ -1065,6 +1098,164 @@ $(function() {
$('#request-for-comments').toggle(isActiveFileSubmission() && !isActiveFileBinary());
};
var initWebsocketConnection = function(url) {
websocket = new WebSocket('ws://' + window.location.hostname + ':' + window.location.port + url);
websocket.onopen = function(evt) { resetOutputTab(); }; // todo show some kind of indicator for established connection
websocket.onclose = function(evt) { /* expected at some point */ };
websocket.onmessage = function(evt) { parseCanvasMessage(evt.data, true); };
websocket.onerror = function(evt) { showWebsocketError(); };
websocket.flush = function() { this.send('\n'); }
};
var initTurtle = function() {
// todo guard clause if turtle is not required for the current exercise
turtlescreen = new Turtle(websocket, turtlecanvas);
if ($('#run').isPresent()) {
$('#run').bind('click', hideCanvas);
}
};
var initPrompt = function() {
if ($('#run').isPresent()) {
$('#run').bind('click', hidePrompt);
}
if ($('#prompt').isPresent()) {
$('#prompt').on('keypress', handlePromptKeyPress);
$('#prompt-submit').on('click', submitPromptInput);
}
}
var executeWebsocketCommand = function(msg) {
if ($.inArray(msg.cmd, commands) == -1) {
console.log("Unknown command: " + msg.cmd);
// skipping unregistered commands is required
// as we may receive mirrored response due to internal behaviour
return;
}
switch(msg.cmd) {
case 'input':
showPrompt();
break;
case 'write':
printWebsocketOutput(msg);
break;
case 'turtle':
showCanvas();
handleTurtleCommand(msg);
break;
case 'turtlebatch':
showCanvas();
handleTurtlebatchCommand(msg);
break;
case 'exit':
killWebsocketAndContainer();
break;
case 'status':
showStatus(msg)
break;
}
};
var printWebsocketOutput = function(msg) {
if (!msg.data) {
return;
}
//msg.data = msg.data.replace(/(\r\n|\n|\r)/gm, "<br />");
msg.data = msg.data.replace(/(\r)/gm, "\n");
var stream = {};
stream[msg.stream] = msg.data;
printOutput(stream, true, 0);
};
var handleTurtleCommand = function(msg) {
if (msg.action in turtlescreen) {
result = turtlescreen[msg.action].apply(turtlescreen, msg.args);
websocket.send(JSON.stringify({cmd: 'result', 'result': result}));
} else {
websocket.send(JSON.stringify({cmd: 'exception', exception: 'AttributeError', message: msg.action}));
}
websocket.flush();
};
var handleTurtlebatchCommand = function(msg) {
for (i = 0; i < msg.batch.length; i++) {
cmd = msg.batch[i];
turtlescreen[cmd[0]].apply(turtlescreen, cmd[1]);
}
};
var handlePromptKeyPress = function(evt) {
if (evt.which === ENTER_KEY_CODE) {
submitPromptInput();
}
}
var submitPromptInput = function() {
var input = $('#prompt-input');
var message = input.val();
websocket.send(JSON.stringify({cmd: 'result', 'data': message}));
websocket.flush();
input.val('');
hidePrompt();
}
var parseCanvasMessage = function(message, recursive) {
var msg;
message = message.replace(/^\s+|\s+$/g, "");
try {
// todo validate json instead of catching
msg = JSON.parse(message);
} catch (e) {
if (!recursive) {
return false;
}
// why does docker sometimes send multiple commands at once?
message = message.replace(/^\s+|\s+$/g, "");
messages = message.split("\n");
for (var i = 0; i < messages.length; i++) {
if (!messages[i]) {
continue;
}
parseCanvasMessage(messages[i], false);
}
return;
}
executeWebsocketCommand(msg);
};
var showPrompt = function() {
if (prompt.isPresent() && prompt.hasClass('hidden')) {
prompt.removeClass('hidden');
}
prompt.focus();
}
var hidePrompt = function() {
if (prompt.isPresent() && !prompt.hasClass('hidden')) {
prompt.addClass('hidden');
}
}
var showCanvas = function() {
if ($('#turtlediv').isPresent()
&& turtlecanvas.hasClass('hidden')) {
// initialize two-column layout
$('#output-col1').addClass('col-lg-7 col-md-7 two-column');
turtlecanvas.removeClass('hidden');
}
};
var hideCanvas = function() {
if ($('#turtlediv').isPresent()
&& !(turtlecanvas.hasClass('hidden'))) {
output = $('#output-col1');
if (output.hasClass('two-column')) {
output.removeClass('col-lg-7 col-md-7 two-column');
}
turtlecanvas.addClass('hidden');
}
};
var requestComments = function(e) {
var user_id = $('#editor').data('user-id')
var exercise_id = $('#editor').data('exercise-id')
@ -1123,6 +1314,7 @@ $(function() {
initializeEventHandlers();
initializeFileTree();
initializeTooltips();
initPrompt();
renderScore();
showFirstFile();
showRequestedTab();

View File

@ -0,0 +1,222 @@
var output;
var editor;
var pipeurl;
var filename;
var pendingChanges = -1;
function Turtle(pipe, canvas) {
var dx, dy, xpos, ypos;
this.canvas = canvas; // jQuery object
this.items = [];
this.canvas.off('click');
this.canvas.click(function (e) {
if (e.eventPhase !== 2) {
return;
}
e.stopPropagation();
dx = this.width / 2;
dy = this.height / 2;
if(e.offsetX==undefined)
{
var offset = canvas.offset();
xpos = e.pageX-offset.left;
ypos = e.pageY-offset.top;
}
else
{
xpos = e.offsetX;
ypos = e.offsetY;
}
pipe.send(JSON.stringify({
'cmd': 'canvasevent',
'type': '<Button-1>',
'x': xpos - dx,
'y': ypos - dy
}));
});
}
Turtle.prototype.update = function () {
var i, k, canvas, ctx, dx, dy, item, c, length;
canvas = this.canvas[0];
ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
length = this.items.length;
dx = canvas.width / 2;
dy = canvas.height / 2;
for (i = 0; i < length; i += 1) {
item = this.items[i];
c = item.coords;
switch (item.type) {
case 'line':
ctx.beginPath();
ctx.moveTo(c[0] + dx, c[1] + dy);
for (k = 2; k < c.length; k += 2) {
ctx.lineTo(c[k] + dx, c[k + 1] + dy);
}
if (this.fill) {
ctx.strokeStyle = this.fill;
}
ctx.stroke();
break;
case 'polygon':
ctx.beginPath();
ctx.moveTo(c[0] + dx, c[1] + dy);
for (k = 2; k < c.length; k += 2) {
ctx.lineTo(c[k] + dx, c[k + 1] + dy);
}
ctx.closePath();
if (item.fill !== "") {
ctx.fillStyle = item.fill;
ctx.strokeStyle = item.fill;
ctx.fill();
}
ctx.stroke();
break;
case 'image':
break;
}
}
}
Turtle.prototype.get_width = function () {
return this.canvas[0].width;
}
Turtle.prototype.get_height = function () {
return this.canvas[0].height;
}
Turtle.prototype.delete = function (item) {
if (item == 'all') {
this.items = [];
} else {
delete this.items[item];
}
}
Turtle.prototype.create_image = function (image) {
this.items.push({type:'image',image:image});
return this.items.length - 1;
}
Turtle.prototype.create_line = function () {
this.items.push({type:'line',
fill: '',
coords:[0,0,0,0],
width:2,
capstyle:'round'});
return this.items.length - 1;
}
Turtle.prototype.create_polygon = function () {
this.items.push({type:'polygon',
// fill: "" XXX
// outline: "" XXX
coords:[0,0,0,0,0,0]
});
return this.items.length - 1;
}
// XXX might make this varargs as in Tkinter
Turtle.prototype.coords = function (item, coords) {
if (coords === undefined) {
return this.items[item].coords;
}
this.items[item].coords = coords;
}
Turtle.prototype.itemconfigure = function (item, key, value) {
this.items[item][key] = value;
}
// value might be undefined
Turtle.prototype.css = function (key, value) {
if (value === undefined) {
return this.canvas.css(key);
} else {
// jQuery return value is confusing when the css is set
this.canvas.css(key, value);
}
}
function run(launchmsg) {
var i, turtlescreen, msg, result, cmd;
$('#assess').empty();
turtlescreen = new Turtle();
output = $('#output');
output.empty();
if (typeof pipeurl === 'undefined') {
if (wp_port == '443') {
pipeurl = 'wss://'+wp_hostname+'/pipe';
} else {
pipeurl = 'ws://'+wp_hostname+':'+wp_port+'/pipe';
}
}
saveFile();
output.pipe = new WebSocket(pipeurl);
output.pipe.onopen = function () {
output.pipe.send(JSON.stringify(launchmsg));
};
output.pipe.onmessage = function (response) {
msg = JSON.parse(response.data);
if (msg.cmd == 'input') {
output.inputelem = $('<input>',{'size':40});
submit = $('<input>',{'type':'submit'});
submit.click(function (){
text = output.inputelem.val();
output.input.replaceWith($('<code>', {text:text+'\n'}));
output.pipe.send(JSON.stringify({'cmd':'inputresult',
'data':text}));
});
output.inputelem.keydown(function(event){
if(event.keyCode == 13){
submit.click();
}
});
output.append($('<code>', {text:msg.data}));
output.input = $('<span>').append(output.inputelem).append(submit);
output.append(output.input);
output.inputelem.focus();
} else if (msg.cmd == 'stop') {
if (launchmsg.cmd == 'runscript') {
if (msg.timedout) {
output.append('<hr>Dein Programm hat zu lange gerechnet und wurde beendet.');
} else {
output.append('<hr>Dein Progamm wurde beendet');
}
}
output.pipe.close();
} else if (msg.cmd == 'passed') {
$('#assess').html("Herzlich Glückwunsch! Dein Programm funktioniert korrekt.");
} else if (msg.cmd == 'failed') {
$('#assess').html(msg.data);
} else if (msg.cmd == 'turtle') {
if (msg.action in turtlescreen) {
result = turtlescreen[msg.action].apply(turtlescreen, msg.args);
output.pipe.send(JSON.stringify({cmd:'result', 'result':result}));
} else {
output.pipe.send(JSON.stringify({cmd:'exception', exception:'AttributeError',
message:msg.action}));
}
} else if (msg.cmd == 'turtlebatch') {
for (i=0; i < msg.batch.length; i += 1) {
cmd = msg.batch[i];
turtlescreen[cmd[0]].apply(turtlescreen, cmd[1]);
}
} else {
if(msg.stream == 'internal') {
output.append('<hr><em>Interner Fehler (bitte melden):</em>\n');
}
else if (msg.stream == 'stderr') {
showConsole();
$('#consoleradio').prop('checked', 'checked');
}
output.append($('<code>',{text:msg.data, 'class':msg.stream}));
}
};
}

View File

@ -62,7 +62,7 @@ class ExercisesController < ApplicationController
end
def exercise_params
params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :team_id, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name)
params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :team_id, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name)
end
private :exercise_params

View File

@ -4,6 +4,7 @@ class SubmissionsController < ApplicationController
include Lti
include SubmissionParameters
include SubmissionScoring
include Tubesock::Hijack
before_action :set_submission, only: [:download_file, :render_file, :run, :score, :show, :statistics, :stop, :test]
before_action :set_docker_client, only: [:run, :test]
@ -70,20 +71,95 @@ class SubmissionsController < ApplicationController
end
def run
with_server_sent_events do |server_sent_event|
output = @docker_client.execute_run_command(@submission, params[:filename])
server_sent_event.write({stdout: output[:stdout]}, event: 'output') if output[:stdout]
server_sent_event.write({stderr: output[:stderr]}, event: 'output') if output[:stderr]
server_sent_event.write({status: output[:status]}, event: 'status')
unless output[:stderr].nil?
if hint = Whistleblower.new(execution_environment: @submission.execution_environment).generate_hint(output[:stderr])
server_sent_event.write(hint, event: 'hint')
else
store_error(output[:stderr])
# TODO reimplement SSEs with websocket commands
# with_server_sent_events do |server_sent_event|
# output = @docker_client.execute_run_command(@submission, params[:filename])
# server_sent_event.write({stdout: output[:stdout]}, event: 'output') if output[:stdout]
# server_sent_event.write({stderr: output[:stderr]}, event: 'output') if output[:stderr]
# unless output[:stderr].nil?
# if hint = Whistleblower.new(execution_environment: @submission.execution_environment).generate_hint(output[:stderr])
# server_sent_event.write(hint, event: 'hint')
# else
# store_error(output[:stderr])
# end
# end
# end
hijack do |tubesock|
Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive?
result = @docker_client.execute_run_command(@submission, params[:filename])
tubesock.send_data JSON.dump({'cmd' => 'status', 'status' => result[:status]})
if result[:status] == :container_running
socket = result[:socket]
socket.on :message do |event|
Rails.logger.info("Docker sending: " + event.data)
handle_message(event.data, tubesock)
end
socket.on :close do |event|
kill_socket(tubesock)
end
tubesock.onmessage do |data|
Rails.logger.info("Client sending: " + data)
# Check wether the client send a JSON command and kill container
# if the command is 'exit', send it to docker otherwise.
begin
parsed = JSON.parse(data)
if parsed['cmd'] == 'exit'
Rails.logger.info("Client killed container.")
@docker_client.kill_container(result[:container])
else
socket.send data
end
rescue JSON::ParserError
socket.send data
end
end
else
kill_socket(tubesock)
end
end
end
def kill_socket(tubesock)
# Hijacked connection needs to be notified correctly
tubesock.send_data JSON.dump({'cmd' => 'exit'})
tubesock.close
end
def handle_message(message, tubesock)
# Handle special commands first
if (/^exit/.match(message))
kill_socket(tubesock)
else
# Filter out information about run_command, test_command, user or working directory
run_command = @submission.execution_environment.run_command
test_command = @submission.execution_environment.test_command
if !(/root|workspace|#{run_command}|#{test_command}/.match(message))
parse_message(message, 'stdout', tubesock)
end
end
end
def parse_message(message, output_stream, socket, recursive = true)
begin
parsed = JSON.parse(message)
socket.send_data message
rescue JSON::ParserError => e
# Check wether the message contains multiple lines, if true try to parse each line
if ((recursive == true) && (message.include? "\n"))
for part in message.split("\n")
self.parse_message(part,output_stream,socket,false)
end
else
parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>message}
socket.send_data JSON.dump(parsed)
end
end
end

View File

@ -1,6 +1,6 @@
#editor.row data-exercise-id=exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-errors-url=execution_environment_errors_path(exercise.execution_environment) data-submissions-url=submissions_path data-user-id=@current_user.id
.col-sm-3 = render('editor_file_tree', files: @files)
#frames.col-sm-9
div class=(@exercise.hide_file_tree ? 'hidden col-sm-3' : 'col-sm-3') = render('editor_file_tree', files: @files)
div id='frames' class=(@exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9')
- @files.each do |file|
= render('editor_frame', exercise: exercise, file: file)
#autosave-label

View File

@ -23,6 +23,10 @@
label
= f.check_box(:public)
= t('activerecord.attributes.exercise.public')
.checkbox
label
= f.check_box(:hide_file_tree)
= t('activerecord.attributes.exercise.hide_file_tree')
h2 = t('activerecord.attributes.exercise.files')
ul#files.list-unstyled
= f.fields_for :files do |files_form|

View File

@ -13,44 +13,46 @@
#development-environment
ul.nav.nav-justified.nav-tabs role='tablist'
li.active
a data-placement='top' data-toggle='tab' data-tooltip=true href='#instructions' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 1')
i.fa.fa-question
= t('activerecord.attributes.exercise.instructions')
li
a data-placement='top' data-toggle='tab' data-tooltip=true href='#workspace' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 2')
a data-placement='top' data-toggle='tab' data-tooltip=true href='#workspace' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 1')
i.fa.fa-code
= t('.workspace')
li
a data-placement='top' data-toggle='tab' data-tooltip=true href='#outputInformation' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 3')
a data-placement='top' data-toggle='tab' data-tooltip=true href='#outputInformation' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 2')
i.fa.fa-terminal
= t('.output')
li
a data-placement='top' data-toggle='tab' data-tooltip=true href='#progress' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 4')
a data-placement='top' data-toggle='tab' data-tooltip=true href='#progress' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 3')
i.fa.fa-line-chart
= t('.progress')
hr
.tab-content
#instructions.tab-pane.active
p = render_markdown(@exercise.instructions)
br
p.text-center
a#start.btn.btn-lg.btn-success
i.fa.fa-code
= t('.start')
#workspace.tab-pane = render('editor', exercise: @exercise, files: @files, submission: @submission)
#workspace.tab-pane.active = render('editor', exercise: @exercise, files: @files, submission: @submission)
#outputInformation.tab-pane data-message-no-output=t('.no_output')
#hint
.panel.panel-warning
.panel-heading = t('.hint')
.panel-body
#output
pre = t('.no_output_yet')
- if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled]
#flowrHint.panel.panel-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
.panel-heading = 'Gain more insights here'
.panel-body
.row
/ #output-col1.col-sm-12
#output-col1
// todo set to full width if turtle isnt used
#prompt.input-group.hidden
span.input-group-addon = 'Your input'
input#prompt-input.form-control type='text'
span.input-group-btn
button#prompt-submit.btn.btn-primary type="button" = 'Send'
#output
pre = t('.no_output_yet')
- if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled]
#flowrHint.panel.panel-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
.panel-heading = 'Gain more insights here'
.panel-body
#output-col2.col-lg-5.col-md-5
#turtlediv
// todo what should the canvas default size be?
canvas#turtlecanvas.hidden width=400 height=400 style='border-style:solid;border-width:thin'
#progress.tab-pane
#results
h2 = t('.results')
@ -79,4 +81,4 @@
- if qa_url
#questions-column
#questions-holder data-url="#{qa_url}/qa/index/#{@exercise.id}/#{@user_id}"
= qa_js_tag
= qa_js_tag

View File

@ -14,6 +14,7 @@ h1
= row(label: 'exercise.team', value: @exercise.team ? link_to(@exercise.team, @exercise.team) : nil)
= row(label: 'exercise.maximum_score', value: @exercise.maximum_score)
= row(label: 'exercise.public', value: @exercise.public?)
= row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?)
= row(label: 'exercise.embedding_parameters') do
= content_tag(:input, nil, class: 'form-control', readonly: true, value: embedding_parameters(@exercise))

View File

@ -7,7 +7,9 @@ default: &default
development:
<<: *default
host: tcp://192.168.59.104:2376
workspace_root: <%= File.join('/', 'shared', Rails.env) %>
ws_host: ws://192.168.59.104:2376
#workspace_root: <%= File.join('/', 'shared', Rails.env) %>
workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %>
production:
<<: *default

View File

@ -28,6 +28,7 @@ de:
execution_environment: Ausführungsumgebung
execution_environment_id: Ausführungsumgebung
files: Dateien
hide_file_tree: Dateibaum verstecken
instructions: Anweisungen
maximum_score: Erreichbare Punktzahl
public: Öffentlich

View File

@ -28,6 +28,7 @@ en:
execution_environment: Execution Environment
execution_environment_id: Execution Environment
files: Files
hide_file_tree: Hide File Tree
instructions: Instructions
maximum_score: Maximum Score
public: Public

View File

@ -0,0 +1,5 @@
class AddHideFileTreeToExercises < ActiveRecord::Migration
def change
add_column :exercises, :hide_file_tree, :boolean
end
end

View File

@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20150903152727) do
ActiveRecord::Schema.define(version: 20150922125415) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -79,6 +79,7 @@ ActiveRecord::Schema.define(version: 20150903152727) do
t.string "user_type"
t.string "token"
t.integer "team_id"
t.boolean "hide_file_tree"
end
create_table "external_users", force: true do |t|

View File

@ -11,6 +11,7 @@ class DockerClient
RETRY_COUNT = 2
attr_reader :container
attr_reader :socket
def self.check_availability!
Timeout.timeout(config[:connection_timeout]) { Docker.version }
@ -41,7 +42,12 @@ class DockerClient
'Memory' => execution_environment.memory_limit.megabytes,
'NetworkDisabled' => !execution_environment.network_enabled?,
'OpenStdin' => true,
'StdinOnce' => true
'StdinOnce' => true,
# required to expose standard streams over websocket
'AttachStdout' => true,
'AttachStdin' => true,
'AttachStderr' => true,
'Tty' => true
}
end
@ -52,6 +58,29 @@ class DockerClient
}
end
def create_socket(container, stderr=false)
# todo factor out query params
# todo separate stderr
query_params = 'logs=1&stream=1&' + (stderr ? 'stderr=1' : 'stdout=1&stdin=1')
# Headers are required by Docker
headers = {'Origin' => 'http://localhost'}
socket = Faye::WebSocket::Client.new(DockerClient.config['ws_host'] + '/containers/' + @container.id + '/attach/ws?' + query_params, [], :headers => headers)
socket.on :error do |event|
Rails.logger.info "Websocket error: " + event.message
end
socket.on :close do |event|
Rails.logger.info "Websocket closed."
end
socket.on :open do |event|
Rails.logger.info "Websocket created."
kill_after_timeout(container)
end
socket
end
def copy_file_to_workspace(options = {})
FileUtils.cp(options[:file].native_file.path, local_file_path(options))
end
@ -118,14 +147,71 @@ class DockerClient
#(tries += 1) <= RETRY_COUNT ? retry : raise(error)
end
[:run, :test].each do |cause|
define_method("execute_#{cause}_command") do |submission, filename, &block|
command = submission.execution_environment.send(:"#{cause}_command") % command_substitutions(filename)
create_workspace_files = proc { create_workspace_files(container, submission) }
execute_command(command, create_workspace_files, block)
def execute_websocket_command(command, before_execution_block, output_consuming_block)
@container = DockerContainerPool.get_container(@execution_environment)
if @container
before_execution_block.try(:call)
# todo catch exception if socket could not be created
@socket ||= create_socket(@container)
# Newline required to flush
@socket.send command + "\n"
{status: :container_running, socket: @socket, container: @container}
else
{status: :container_depleted}
end
end
def kill_after_timeout(container)
"""
We need to start a second thread to kill the websocket connection,
as it is impossible to determine whether further input is requested.
"""
Thread.new do
timeout = @execution_environment.permitted_execution_time.to_i # seconds
sleep(timeout)
Rails.logger.info("Killing container after timeout of " + timeout.to_s + " seconds.")
kill_container(container)
end
end
def kill_container(container)
"""
Please note that we cannot properly recycle containers when using
websockets because it is impossible to determine whether a program
is still running.
"""
# remove container from pool, then destroy it
(DockerContainerPool.config[:active]) ? DockerContainerPool.remove_from_all_containers(container, @execution_environment) :
#destroy container
self.class.destroy_container(container)
# if we recylce containers, we start a fresh one
if(DockerContainerPool.config[:active] && RECYCLE_CONTAINERS)
# create new container and add it to @all_containers and @containers.
container = self.class.create_container(@execution_environment)
DockerContainerPool.add_to_all_containers(container, @execution_environment)
end
end
def execute_run_command(submission, filename, &block)
"""
Run commands by attaching a websocket to Docker.
"""
command = submission.execution_environment.send(:"run_command") % command_substitutions(filename)
create_workspace_files = proc { create_workspace_files(container, submission) }
execute_websocket_command(command, create_workspace_files, block)
end
def execute_test_command(submission, filename, &block)
"""
Stick to existing Docker API with exec command.
"""
command = submission.execution_environment.send(:"test_command") % command_substitutions(filename)
create_workspace_files = proc { create_workspace_files(container, submission) }
execute_command(command, create_workspace_files, block)
end
def self.find_image_by_tag(tag)
Docker::Image.all.detect { |image| image.info['RepoTags'].flatten.include?(tag) }
end

View File

@ -1,5 +1,5 @@
class PyUnitAdapter < TestingFrameworkAdapter
COUNT_REGEXP = /Ran (\d+) tests/
COUNT_REGEXP = /Ran (\d+) test/
FAILURES_REGEXP = /FAILED \(failures=(\d+)\)/
def self.framework_name

12
webpython/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM ubuntu:14.04
MAINTAINER "Martin v. Löwis"
RUN locale-gen en_US.UTF-8
ENV LANG en_US.UTF-8
ENV PYTHONPATH $PYTHONPATH:/usr/lib/python3.4:/workspace
ENV PATH $PATH:/usr/lib/python3.4
ADD assess.py /usr/lib/python3.4/assess.py
ADD webpython.py /usr/lib/python3.4/webpython.py
RUN rm /usr/lib/python3.4/turtle.py
ADD turtle.py /usr/lib/python3.4/turtle.py
RUN adduser --disabled-password --gecos Python python
USER python

58
webpython/Makefile Normal file
View File

@ -0,0 +1,58 @@
CODEMIRROR=html/js/codemirror.js
all: logs
logs:
mkdir logs
# These are put into source control
generated: $(CODEMIRROR)
$(CODEMIRROR): CodeMirror/lib/codemirror.js CodeMirror/mode/python/python.js
(cd CodeMirror; bin/compress codemirror python) > $@
run: all kill
twistd -l logs/webpython.log -y webpython.tac
runpy: killpy
twistd --pidfile pylaunch.pid -l logs/pylaunch.log -y pylaunch.tac
manager: all killmanager
twistd --pidfile manager.pid -l logs/manager.log -y manager.tac
kill:
if [ -f twistd.pid ];\
then\
kill `cat twistd.pid`;\
fi
killpy:
if [ -f pylaunch.pid ];\
then\
kill `cat pylaunch.pid`;\
fi
killmanager:
if [ -f manager.pid ];\
then\
kill `cat manager.pid`;\
fi
docker:
docker.io build --rm -t webpython .
update:
git pull
make docker
service webpython-worker restart
rmexited:
docker.io ps -a|grep 'Exit '|awk '{print $$1;}'|xargs docker.io rm
rmi:
docker.io rmi $$(docker.io images | grep "^<none>" | awk '{print $$3;}')
killold:
-docker.io ps | egrep 'hours? ago' |awk '{print $$1}'|xargs docker.io kill
-killall -o 30m -9 python3

47
webpython/README.md Normal file
View File

@ -0,0 +1,47 @@
Local setup
===========
1. `git checkout webpython-hybrid`
2. Make sure to install all dependencies and migrations:
rake db:migrate
bundle install
3. Create a new docker image containing the Turtle library and the i/o wrapper:
cd webpython
docker build -t IMAGE_NAME .
4. Configure your Docker host at `config/docker.yml.erb`. Make sure to add a websocket host, for example like this (this is probably different for you):
host: tcp://localhost:2375
ws_host: ws://localhost:2375
5. Run the CodeOcean server with `rails s -p 3333`
6. Login with admin@example.org (pw: admin) and create a new execution environment picking the newly created Docker image from the dropdown. Set the initial command to:
cd /usr/lib/python3.4 && python3 webpython.py
7. Create a new exercise for the newly created execution environment with an arbritrary main file.
8. Implement the exercise. The code below can be used as an example to see the canvas and I/O in action:
import turtle
wn = turtle.Screen()
alex = turtle.Turtle()
# i/o test
print("hello!")
print("please enter your name")
name = input()
print("your name is", name)
# canvas test
alex.forward(50)
alex.right(90)
alex.forward(30)
alex.right(90)
alex.forward(30)
wn.mainloop()

234
webpython/assess.py Normal file
View File

@ -0,0 +1,234 @@
import webpython, contextlib, io, json, sys, turtle, ast
from webpython import shell
turtle_operations = []
bindings = {}
class RecordingPen:
_pen = None
_screen = None
def __init__(self):
self.operations = turtle_operations
self._pos = (0,0)
turtle_operations.append(('__init__',()))
def reset(self):
turtle_operations.clear()
def onclick(self, fun, btn=1, add=None):
self.operations.append(('onclick', (fun,)))
def eventfun(event):
fun(event.x, event.y)
bindings['<Button-1>'] = eventfun
def goto(self, x, y):
self._pos = (x,y)
self.operations.append(('goto', (x,y)))
def pos(self):
self.operations.append(('pos', ()))
return self._pos
def __getattr__(self, method):
def func(*args):
self.operations.append((method, args))
return func
class FakeCanvas(turtle.WebCanvas):
def flushbatch(self):
pass
def get_width(self):
return 400
def get_height(self):
return 400
def delete(self, item):
pass
def css(self, key, value):
pass
fake_events = []
def mainloop():
while fake_events:
e = turtle.Event(fake_events.pop(0))
if e.type in bindings:
bindings[e.type](e)
turtle.Turtle = RecordingPen
turtle.WebCanvas = FakeCanvas
pen = turtle._getpen()
turtle.mainloop = mainloop
def filter_operations(name):
return [o for o in turtle_operations if o[0] == name]
@contextlib.contextmanager
def capture():
global captured_out
import sys
oldout,olderr = sys.stdout, sys.stderr
try:
out=[io.StringIO(), io.StringIO()]
captured_out = out
sys.stdout,sys.stderr = out
yield out
finally:
sys.stdout,sys.stderr = oldout, olderr
out[0] = out[0].getvalue()
out[1] = out[1].getvalue()
def get_source():
message = json.loads(sys.argv[1])
return message['data']
def get_ast():
s = get_source()
return ast.parse(s, "programm.py", "exec")
def has_bare_except():
for node in ast.walk(get_ast()):
if isinstance(node, ast.ExceptHandler):
if node.type is None:
return True
return False
def runcaptured(prefix='', tracing=None, variables=None, source=''):
#message = json.loads(sys.argv[1])
#source = prefix + message['data']
with open("programm.py", "w", encoding='utf-8') as f:
f.write(source)
c = compile(source, "programm.py", 'exec')
with capture() as out, trace(tracing):
if variables is None:
variables = {}
exec(c, variables)
return source, out[0], out[1], variables
def runfunc(func, *args, tracing=None):
with capture() as out, trace(tracing):
res = func(*args)
return out[0], out[1], res
def passed():
msg_in = json.loads(sys.argv[1])
msg_out = {'cmd':'passed'}
msg_out['lis_outcome_service_url'] = msg_in['lis_outcome_service_url']
msg_out['lis_result_sourcedid'] = msg_in['lis_result_sourcedid']
webpython.shell.sendpickle(msg_out)
def failed(msg):
msg_in = json.loads(sys.argv[1])
msg_out = {'cmd':'failed', 'data':'Dein Programm ist leider falsch:\n'+msg}
msg_out['lis_outcome_service_url'] = msg_in['lis_outcome_service_url']
msg_out['lis_result_sourcedid'] = msg_in['lis_result_sourcedid']
webpython.shell.sendpickle(msg_out)
def modified(variables, name, val):
if variables.get(name) != val:
msg_in = json.loads(sys.argv[1])
msg_out = {'cmd':'failed',
'data':('Bitte lösche Deine Zuweisung der Variable %s, '+
'damit wir Dein Programm überprüfen können.') % name}
msg_out['lis_outcome_service_url'] = msg_in['lis_outcome_service_url']
msg_out['lis_result_sourcedid'] = msg_in['lis_result_sourcedid']
webpython.shell.sendpickle(msg_out)
return True
return False
undefined = object()
def getvar(variables, name):
try:
return variables['name']
except KeyError:
name = name.lower()
for k,v in variables.items():
if k.lower() == name:
return v
return undefined
def _match(n1, n2):
if n1 == n2:
return True
if n1 is None or n2 is None:
return False
return n1.lower() == n2.lower()
class Call:
def __init__(self, name, args):
self.name = name
self.args = args
self.calls = []
self.current = None
def findcall(self, f):
if _match(self.name, f):
return self
for c in self.calls:
r = c.findcall(f)
if r:
return r
return None
def calling(self, caller, callee):
if _match(self.name, caller):
for c in self.calls:
if _match(c.name, callee):
return True
for c in self.calls:
if c.calling(caller, callee):
return True
return False
def countcalls(self, caller, callee):
calls = 0
if _match(self.name, caller):
for c in self.calls:
if _match(c.name, callee):
calls += 1
return calls
for c in self.calls:
r = c.countcalls(caller, callee)
if r > 0:
return r
return 0
class Tracing(Call):
def __init__(self):
Call.__init__(self, None, None)
def trace(self, frame, event, arg):
if event == 'call':
c = Call(frame.f_code.co_name, frame.f_locals.copy())
cur = self
while cur.current:
cur = cur.current
cur.calls.append(c)
cur.current = c
return self.trace
elif event in ('return', 'exception'):
cur = self
if not cur.current:
# XXX return without call? happens when invocation of top function fails
return
while cur.current.current:
cur = cur.current
cur.current = None
def start(self):
sys.settrace(self.trace)
def stop(self):
sys.settrace(None)
@contextlib.contextmanager
def trace(t):
try:
if t:
t.start()
yield
finally:
if t:
t.stop()

4078
webpython/turtle.py Normal file

File diff suppressed because it is too large Load Diff

178
webpython/webpython.py Normal file
View File

@ -0,0 +1,178 @@
#!/usr/bin/python3
# Main interpreter entry for webpython
import io, select, sys, os, threading, code
import pickle, struct, builtins, json
#, ressource
from queue import Queue
from argparse import ArgumentParser
# hard limit to 64M
#try:
# resource.setrlimit(resource.RLIMIT_AS, (1<<26, 1<<26))
#except ValueError:
# tried to raise it
# pass
# output limit 16MiB
output_capacity = 16*1024*1024
# adapted from IDLE (PyShell.py)
class PseudoFile(io.TextIOBase):
def __init__(self, shell, name):
self.shell = shell
self._name = name
@property
def encoding(self):
return "UTF-8"
@property
def name(self):
return '<%s>' % self._name
def isatty(self):
return True
class PseudoInputFile(PseudoFile):
def __init__(self, shell, name):
PseudoFile.__init__(self, shell, name)
self._line_buffer = ''
def readable(self):
return True
def read(self, size=-1):
if self.closed:
raise ValueError("read from closed file")
if size is None:
size = -1
elif not isinstance(size, int):
raise TypeError('must be int, not ' + type(size).__name__)
result = self._line_buffer
self._line_buffer = ''
if size < 0:
while True:
line = self.shell.readline()
if not line: break
result += line
else:
while len(result) < size:
line = self.shell.readline()
if not line: break
result += line
self._line_buffer = result[size:]
result = result[:size]
return result
def readline(self, size=-1):
if self.closed:
raise ValueError("read from closed file")
if size is None:
size = -1
elif not isinstance(size, int):
raise TypeError('must be int, not ' + type(size).__name__)
line = self._line_buffer or self.shell.readline()
if size < 0:
size = len(line)
self._line_buffer = line[size:]
return line[:size]
def close(self):
self.shell.close()
class PseudoOutputFile(PseudoFile):
def writable(self):
return True
def write(self, s):
if self.closed:
raise ValueError("write to closed file")
if not isinstance(s, str):
raise TypeError('must be str, not ' + type(s).__name__)
return self.shell.write(s, self._name)
# RPC proxy
orig_stdin = sys.stdin
orig_stdout = sys.stdout
orig_stderr = sys.stderr
class Shell:
def __init__(self):
self.stdin = io.FileIO(0)
self.buf = b''
self.canvas = []
self.messages = []
self.capacity = output_capacity
# PseudoFile interaction
#def readline(self):
# self.sendpickle({'cmd':'readline',
# 'stream':'stdin',
# })
# return self.inputq.get()
def write(self, data, name):
self.sendpickle({'cmd':'write',
'stream':name,
'data':data
})
def input(self, prompt=''):
self.sendpickle({'cmd':'input',
'stream':'stdin',
'data':prompt})
result = self.receivemsg()
return result['data']
# internal
def sendpickle(self, data):
data = json.dumps(data) + "\n\r"
self.capacity -= len(data)
if self.capacity < 0:
data = json.dumps({'cmd':'stop',
'timedout':True}, 2)
orig_stdout.write(data)
raise SystemExit
orig_stdout.write(data)
def receivepickle(self):
msg = json.loads(orig_stdin.readline())
if msg['cmd'] == 'canvasevent':
self.canvas.append(msg)
else:
self.messages.append(msg)
def receivemsg(self):
while not self.messages:
self.receivepickle()
return self.messages.pop()
def receivecanvas(self):
while not self.canvas:
self.receivepickle()
return self.canvas.pop(0)
# Hide 0/1 from sys
shell = Shell()
sys.__stdin__ = sys.stdin = PseudoInputFile(shell, 'stdin')
sys.__stdout__ = sys.stdout = PseudoOutputFile(shell, 'stdout')
#sys.__stderr__ = sys.stderr = PseudoOutputFile(shell, 'stderr')
builtins.input = shell.input
#iothread = threading.Thread(target=shell.run)
#iothread.start()
if __name__ == '__main__':
parser = ArgumentParser(description='A python interpreter that generates json commands based on the standard I/O streams.')
parser.add_argument('-f', '--filename', type=str, required=True, default='exercise.py', help='Python file to be interpreted.')
args = parser.parse_args()
filepath = os.path.join("/", "workspace", args.filename)
with open(filepath, "r", encoding='utf-8') as f:
script = f.read()
c = compile(script, args.filename, 'exec')
exec(c, {})
# work-around for docker not terminating properly
shell.sendpickle({'cmd':'exit'})