Files
codeocean/app/controllers/submissions_controller.rb
Sebastian Serth 0583076c2b Fix Thread leakage when scoring or testing submissions
It is discouraged to do anything directly within the
Tubesock hijack block. We might only use the callbacks
(onopen, onmessage, onclose, onerror). Otherwise,
Tubesock might not close all Threads correctly and
will keep them awake every five seconds.
2022-04-07 19:57:31 +02:00

344 lines
11 KiB
Ruby

# frozen_string_literal: true
class SubmissionsController < ApplicationController
include ActionController::Live
include CommonBehavior
include Lti
include SubmissionParameters
include Tubesock::Hijack
before_action :require_user!
before_action :set_submission, only: %i[download download_file render_file run score show statistics test]
before_action :set_files, only: %i[download show]
before_action :set_files_and_specific_file, only: %i[download_file render_file run test]
before_action :set_mime_type, only: %i[download_file render_file]
skip_before_action :verify_authenticity_token, only: %i[download_file render_file]
def create
@submission = Submission.new(submission_params)
authorize!
create_and_respond(object: @submission)
end
def download
raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
id_file = create_remote_evaluation_mapping
stringio = Zip::OutputStream.write_buffer do |zio|
@files.each do |file|
zio.put_next_entry(file.filepath)
zio.write(file.content.presence || file.native_file.read)
end
# zip exercise description
zio.put_next_entry("#{t('activerecord.models.exercise.one')}.txt")
zio.write("#{@submission.exercise.title}\r\n======================\r\n")
zio.write(@submission.exercise.description)
# zip .co file
zio.put_next_entry('.co')
zio.write(File.read(id_file))
File.delete(id_file) if File.exist?(id_file)
# zip client scripts
scripts_path = 'app/assets/remote_scripts'
Dir.foreach(scripts_path) do |file|
next if (file == '.') || (file == '..')
zio.put_next_entry(File.join('.scripts', File.basename(file)))
zio.write(File.read(File.join(scripts_path, file)))
end
end
send_data(stringio.string, filename: "#{@submission.exercise.title.tr(' ', '_')}.zip")
end
def download_file
raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
if @file.native_file?
send_file(@file.native_file.path)
else
send_data(@file.content, filename: @file.name_with_extension)
end
end
def index
@search = Submission.ransack(params[:q])
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
def render_file
if @file.native_file?
send_file(@file.native_file.path, disposition: 'inline')
else
render(plain: @file.content)
end
end
def run
# These method-local socket variables are required in order to use one socket
# in the callbacks of the other socket. As the callbacks for the client socket
# are registered first, the runner socket may still be nil.
client_socket, runner_socket = nil
hijack do |tubesock|
client_socket = tubesock
client_socket.onopen do |_event|
kill_client_socket(client_socket) if @embed_options[:disable_run]
end
client_socket.onclose do |_event|
runner_socket&.close(:terminated_by_client)
end
client_socket.onmessage do |raw_event|
# Obviously, this is just flushing the current connection: Filtering.
next if raw_event == "\n"
# Otherwise, we expect to receive a JSON: Parsing.
event = JSON.parse(raw_event).deep_symbolize_keys
case event[:cmd].to_sym
when :client_kill
close_client_connection(client_socket)
Rails.logger.debug('Client exited container.')
when :result, :canvasevent, :exception
# The client cannot send something before the runner connection is established.
if runner_socket.present?
runner_socket.send_data raw_event
else
Rails.logger.info("Could not forward data from client because runner connection was not established yet: #{event[:data].inspect}")
end
else
Rails.logger.info("Unknown command from client: #{event[:cmd]}")
Sentry.set_extras(event: event)
Sentry.capture_message("Unknown command from client: #{event[:cmd]}")
end
rescue JSON::ParserError => e
Rails.logger.info("Data received from client is not valid json: #{raw_event.inspect}")
Sentry.set_extras(data: raw_event)
Sentry.capture_exception(e)
rescue TypeError => e
Rails.logger.info("JSON data received from client cannot be parsed as hash: #{raw_event.inspect}")
Sentry.set_extras(data: raw_event)
Sentry.capture_exception(e)
end
end
@output = +''
durations = @submission.run(@file) do |socket|
runner_socket = socket
client_socket.send_data JSON.dump({cmd: :status, status: :container_running})
runner_socket.on :stdout do |data|
json_data = prepare data, :stdout
@output << json_data[0, max_output_buffer_size - @output.size]
client_socket.send_data(json_data)
end
runner_socket.on :stderr do |data|
json_data = prepare data, :stderr
@output << json_data[0, max_output_buffer_size - @output.size]
client_socket.send_data(json_data)
end
runner_socket.on :exit do |exit_code|
exit_statement =
if @output.empty? && exit_code.zero?
t('exercises.implement.no_output_exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
elsif @output.empty?
t('exercises.implement.no_output_exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
elsif exit_code.zero?
"\n#{t('exercises.implement.exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
else
"\n#{t('exercises.implement.exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
end
client_socket.send_data JSON.dump({cmd: :write, stream: :stdout, data: "#{exit_statement}\n"})
close_client_connection(client_socket)
end
end
@container_execution_time = durations[:execution_duration]
@waiting_for_container_time = durations[:waiting_duration]
rescue Runner::Error::ExecutionTimeout => e
client_socket.send_data JSON.dump({cmd: :status, status: :timeout})
close_client_connection(client_socket)
Rails.logger.debug { "Running a submission timed out: #{e.message}" }
@output = "timeout: #{@output}"
extract_durations(e)
rescue Runner::Error => e
client_socket.send_data JSON.dump({cmd: :status, status: :container_depleted})
close_client_connection(client_socket)
Rails.logger.debug { "Runner error while running a submission: #{e.message}" }
extract_durations(e)
ensure
save_run_output
end
def score
hijack do |tubesock|
tubesock.onopen do |_event|
kill_client_socket(tubesock) if @embed_options[:disable_score]
tubesock.send_data(JSON.dump(@submission.calculate_score))
# To enable hints when scoring a submission, uncomment the next line:
# send_hints(tubesock, StructuredError.where(submission: @submission))
kill_client_socket(tubesock)
end
rescue Runner::Error => e
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted})
kill_client_socket(tubesock)
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" }
end
end
def show; end
def statistics; end
def test
hijack do |tubesock|
tubesock.onopen do |_event|
kill_client_socket(tubesock) if @embed_options[:disable_run]
tubesock.send_data(JSON.dump(@submission.test(@file)))
kill_client_socket(tubesock)
end
rescue Runner::Error => e
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted})
kill_client_socket(tubesock)
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" }
end
end
private
def authorize!
authorize(@submission || @submissions)
end
def close_client_connection(client_socket)
# search for errors and save them as StructuredError (for scoring runs see submission.rb)
errors = extract_errors
send_hints(client_socket, errors)
kill_client_socket(client_socket)
end
def kill_client_socket(client_socket)
client_socket.send_data JSON.dump({cmd: :exit})
client_socket.close
end
def create_remote_evaluation_mapping
user = @submission.user
exercise_id = @submission.exercise_id
remote_evaluation_mapping = RemoteEvaluationMapping.create(
user: user,
exercise_id: exercise_id,
study_group_id: session[:study_group_id]
)
# create .co file
path = "tmp/#{user.id}.co"
# parse validation token
content = "#{remote_evaluation_mapping.validation_token}\n"
# parse remote request url
content += "#{request.base_url}/evaluate\n"
@submission.files.each do |file|
content += "#{file.filepath}=#{file.file_id}\n"
end
File.write(path, content)
path
end
def extract_durations(error)
@container_execution_time = error.execution_duration
@waiting_for_container_time = error.waiting_duration
end
def extract_errors
results = []
if @output.present?
@submission.exercise.execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze
results << StructuredError.create_from_template(template, @output, @submission) if pattern.match(@output)
end
end
results
end
def max_output_buffer_size
if @submission.cause == 'requestComments'
5000
else
500
end
end
def prepare(data, stream)
if valid_command? data
data
else
JSON.dump({cmd: :write, stream: stream, data: data})
end
end
def sanitize_filename
params[:filename].gsub(/\.json$/, '')
end
# save the output of this "run" as a "testrun" (scoring runs are saved in submission.rb)
def save_run_output
Testrun.create(
file: @file,
cause: 'run',
submission: @submission,
output: @output,
container_execution_time: @container_execution_time,
waiting_for_container_time: @waiting_for_container_time
)
end
def send_hints(tubesock, errors)
return if @embed_options[:disable_hints]
errors = errors.to_a.uniq(&:hint)
errors.each do |error|
tubesock.send_data JSON.dump({cmd: 'hint', hint: error.hint, description: error.error_template.description})
end
end
def set_files_and_specific_file
# @files contains all visible files for the user
# @file contains the specific file requested for run / test / render / ...
set_files
@file = @files.detect {|file| file.filepath == sanitize_filename }
head :not_found unless @file
end
def set_files
@files = @submission.collect_files.select(&:visible)
end
def set_mime_type
@mime_type = Mime::Type.lookup_by_extension(@file.file_type.file_extension.gsub(/^\./, ''))
response.headers['Content-Type'] = @mime_type.to_s
end
def set_submission
@submission = Submission.find(params[:id])
authorize!
end
def valid_command?(data)
parsed = JSON.parse(data)
parsed.instance_of?(Hash) && parsed.key?('cmd')
rescue JSON::ParserError
false
end
end