Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for interactive problems #261

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/evaluators_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ class EvaluatorsController < ApplicationController

def permitted_params
@_permitted_params ||= begin
permitted_attributes = [:name, :description, :source, :language_id]
permitted_attributes = [:name, :description, :source, :language_id, :interactive_processes]
permitted_attributes << :owner_id if policy(@evaluator || Evaluator).transfer?
params.require(:evaluator).permit(*permitted_attributes)
end
Expand Down
1 change: 1 addition & 0 deletions app/models/evaluator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ class Evaluator < ActiveRecord::Base
belongs_to :language

validates :name, :presence => true
validates :interactive_processes, :presence => true, :numericality => { :greater_than_or_equal_to => 0, :less_than_or_equal_to => 2 }

end
11 changes: 9 additions & 2 deletions app/services/isolate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ class Isolate
#
# Alternatively, the isolate box is passed as an argument to a block with an arity, which is not instance_exec-ed
def self.box options = {}, &block
options.reverse_merge!(:cg => has_cgroups?).assert_valid_keys(:cg)
raise CGroupsUnavailableError if options[:cg] && !has_cgroups?
isolate = self.new(options)
yield isolate if block_given?
true
Expand Down Expand Up @@ -50,6 +48,13 @@ def exec command, options = {}
end
end

# Spawn a single command in isolate context
def spawn_command command, options = {}
sandbox_command(command, options) do |command, options|
spawn(*command, options)
end
end

# popen a single command in isolate context
#
# Example:
Expand Down Expand Up @@ -177,6 +182,8 @@ def read_pipe_limited(pipe, count)
protected

def initialize(options = {})
options.reverse_merge!(:cg => self.class.has_cgroups?).assert_valid_keys(:cg)
raise CGroupsUnavailableError if options[:cg] && !self.class.has_cgroups?
@has_cgroup = !!options[:cg]
@box_id = Kernel.send(:`, "isolock --lock -- #{"--cg" if has_cgroup?}").to_i
raise LockError unless $?.success?
Expand Down
4 changes: 4 additions & 0 deletions app/views/evaluators/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
<%= f.label :language_id %><br>
<%= f.select :language_id, grouped_options_for_select(Language.grouped_submission_options, @evaluator.language_id), :include_blank => true %>
</div>
<div class="field">
<%= f.label :interactive_processes %><br />
<%= f.text_field :interactive_processes %>
</div>
<div class="field">
<%= f.label :owner_id %><br />
<% if policy(@evaluator).transfer? %>
Expand Down
2 changes: 2 additions & 0 deletions app/views/evaluators/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
<p>
<b>Language:</b>
<%= @evaluator.language&.name %><br>
<b>Interactive processes:</b>
<%= @evaluator.interactive_processes %><br>
<b>Source:</b>
<%= predisplay @evaluator.source, language: @evaluator.language&.lexer %>
</p>
Expand Down
164 changes: 155 additions & 9 deletions app/workers/judge_submission_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def judge
end

private
attr_accessor :problem, :box, :tmpdir
attr_accessor :problem, :box, :interactive_boxes, :tmpdir

def time_limit
problem.time_limit || 0.001
Expand All @@ -154,18 +154,22 @@ def extra_time
end

def wall_time
time_limit*3+extra_time+5
time_limit*2+extra_time+1
end

def setup_judging
self.problem = submission.problem
self.box = Isolate.new
self.interactive_boxes = Array.new(problem.evaluator&.interactive_processes || 0) { Isolate.new }
Dir.mktmpdir do |tmpdir|
self.tmpdir = tmpdir
Isolate.box do |box|
self.box = box
yield
end or raise Isolate::LockError, "Error locking box"
yield
end
ensure
box.send(:destroy) if !box.nil?
interactive_boxes.each do |box|
box.send(:destroy) if !box.nil?
end if !interactive_boxes.nil?
end

def compile!(source, language, output)
Expand All @@ -177,15 +181,19 @@ def compile!(source, language, output)
end

def judge_test_case(test_case, run_command, eval_command, resource_limits)
FileUtils.copy(File.expand_path(exe_filename, tmpdir), box.expand_path(exe_filename))
result = run_test_case(test_case, run_command, resource_limits)
result['evaluator'] = evaluate_output(test_case, result['output'], result['output_size'], eval_command)
if problem.evaluator&.interactive_processes&.positive?
result = run_interactive(test_case, run_command, eval_command, resource_limits)
else
result = run_test_case(test_case, run_command, resource_limits)
result['evaluator'] = evaluate_output(test_case, result['output'], result['output_size'], eval_command)
end
result['log'] = truncate_output(result['log']) # log only a small portion
result['output'] = truncate_output(result['output'].slice(0,100)) # store only a small portion
result
end

def run_test_case(test_case, run_command, resource_limits = {})
FileUtils.copy(File.expand_path(exe_filename, tmpdir), box.expand_path(exe_filename))
stream_limit = OutputBaseLimit + test_case.output.bytesize*2
run_opts = resource_limits.reverse_merge(:output_limit => stream_limit, :clean_utf8 => true)
if submission.input.nil?
Expand Down Expand Up @@ -254,6 +262,144 @@ def evaluate_output(test_case, output, output_size, eval_command)
box.clean!
end

def run_interactive(test_case, run_command, eval_command, resource_limits)
stream_limit = OutputBaseLimit + test_case.output.bytesize*2
num_processes = problem.evaluator.interactive_processes

metafiles = []
boxfiles = []
logfiles = []
manager_pipes = []
user_pids = []

interactive_boxes.each_with_index do |box, index|
FileUtils.copy(File.expand_path(exe_filename, tmpdir), box.expand_path(exe_filename))
metafiles << Tempfile.new('metafile')
boxfiles << Tempfile.new('boxfile')
logfiles << box.tmpfile
manager_to_user = IO.pipe
user_to_manager = IO.pipe
manager_pipes << [user_to_manager[0], manager_to_user[1]]

run_opts = resource_limits.merge(:meta => metafiles.last.path, :err => boxfiles.last, :in => manager_to_user[0], :out => user_to_manager[1], :stderr => logfiles.last)
box_run_command = run_command
if num_processes > 1
box_run_command += " " + index.to_s
end
user_pids << box.spawn_command(box_run_command, run_opts)

manager_to_user[0].close
user_to_manager[1].close
end

expected = conditioned_output(test_case.output)
FileUtils.copy(File.expand_path(EvalFileName, tmpdir), box.expand_path(EvalFileName))
manager_resource_limits = { :mem => 524288, :time => num_processes * time_limit + 15, :wall_time => num_processes * wall_time + 30 }

eval_output = nil
eval_result = {}
str_to_pipe(expected) do |output_stream|
run_opts = manager_resource_limits.merge(:processes => true, 4 => output_stream, :stdin_data => test_case.input, :output_limit => OutputBaseLimit + test_case.output.bytesize*4, :clean_utf8 => true, :inherit_fds => true)
manager_pipes.each_with_index do |pipes, index|
run_opts.merge!(index * 2 + 5 => pipes[0], index * 2 + 6 => pipes[1])
end
(stdout,), (eval_result['log'],eval_result['log_size']), (eval_result['box'],), eval_result['meta'], status = box.capture5("#{eval_command}", run_opts)
eval_result['log'] = truncate_output(eval_result['log'])
eval_output = stdout.strip.split(nil,2)
eval_result['stat'] = status.exitstatus
end

manager_pipes.each do |pipes|
pipes[0].close
pipes[1].close
end

if eval_output.empty?
eval_result.delete('evaluation') # error
else
eval_result['evaluation'] = eval_output[0].to_d
eval_result['message'] = truncate_output(eval_output[1] || "")
end

r = { 'evaluator' => eval_result }

r['stat'] = 0
user_pids.each do |pid|
exit_status = Process.detach(pid).value.exitstatus
r['stat'] = r['stat'].nonzero? || exit_status
end

metafiles.each do |metafile|
metafile.open
meta = Isolate.parse_meta(metafile.read)

if r['meta'].nil?
r['meta'] = meta
else
# Merge execution stats
r['meta']['time'] += meta['time']
r['meta']['time-wall'] = [r['meta']['time-wall'], meta['time-wall']].max
r['meta']['cg-mem'] += meta['cg-mem'] if meta.has_key?('cg-mem')
r['meta']['max-rss'] += meta['max-rss']

# Use first non-OK status and related values
if r['meta']['status'] == 'OK' && meta['status'] != 'OK'
r['meta']['status'] = meta['status']
r['meta']['killed'] = meta['killed'] if meta.has_key?('killed')
r['meta']['exitcode'] = meta['exitcode'] if meta.has_key?('exitcode')
r['meta']['exitsig'] = meta['exitsig'] if meta.has_key?('exitsig')
r['meta']['message'] = meta['message'] if meta.has_key?('message')
end
end
metafile.close
end

# Check for MLE/TLE based on merged execution stats
if r['meta']['status'] == 'OK'
if (r['meta']['cg-mem'] || r['meta']['max-rss']).to_f > memory_limit*1024
r['meta']['status'] = 'SG'
r['meta']['exitsig'] = 9
r['meta']['message'] = "Memory Limit Exceeded"
r['meta']['cg-mem'] = [r['meta']['cg-mem'],memory_limit*1024].min if r['meta'].has_key?('cg-mem')
r['meta']['max-rss'] = [r['meta']['max-rss'],memory_limit*1024].min
Comment on lines +363 to +364
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small issue: The stored memory usage should probably get capped like this regardless of whether the previous status is OK. (r['meta']['status'] == 'OK' on line 358).

Otherwise, in some cases the memory usage displayed on a submission's page may be much higher than the memory limit, since this code won't be run if one of the program instances causes some other error (RE, TLE), on top of exceeding the combined memory limit.

r['stat'] = 1
elsif r['meta']['time'] > time_limit.to_f
r['meta']['status'] = 'TO'
r['meta']['message'] = "Time Limit Exceeded"
r['stat'] = 1
end
end

r['box'] = ''
boxfiles.each do |boxfile|
boxfile.open
r['box'] << boxfile.read
boxfile.close
end

r['log'] = ''
r['log_size'] = 0
interactive_boxes.zip(logfiles).each do |box, logfile|
stderr = File.open(box.expand_path(logfile)) { |f| box.read_pipe_limited(f, stream_limit) }
(log, log_size) = box.clean_utf8(stderr)
r['log'] << log
r['log_size'] += log_size
end
r['log'] = truncate_output(r['log'])

r['output'] = '' # no user output
r['output_size'] = 0

r['time'] = [r['meta']['time'],time_limit.to_f].min

return r
ensure
metafiles.each(&:close!)
boxfiles.each(&:close!)
interactive_boxes.each(&:clean!)
box.clean!
end

def grade_test_set test_set, evaluated_test_cases
result = {'cases' => []}
pending, error, sig = false, false, false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddInteractiveProcessesToEvaluators < ActiveRecord::Migration
def change
add_column :evaluators, :interactive_processes, :integer, null: false, default: 0
end
end
11 changes: 6 additions & 5 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 20230225054132) do
ActiveRecord::Schema.define(version: 20230225064438) do

# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
Expand Down Expand Up @@ -87,13 +87,14 @@
end

create_table "evaluators", force: :cascade do |t|
t.string "name", null: false
t.text "description", default: "", null: false
t.text "source", default: "", null: false
t.integer "owner_id", null: false
t.string "name", null: false
t.text "description", default: "", null: false
t.text "source", default: "", null: false
t.integer "owner_id", null: false
t.datetime "created_at"
t.datetime "updated_at"
t.integer "language_id"
t.integer "interactive_processes", default: 0, null: false
end

create_table "file_attachments", force: :cascade do |t|
Expand Down
Loading