diff --git a/app/controllers/evaluators_controller.rb b/app/controllers/evaluators_controller.rb index 1c61e1fa..5a636055 100644 --- a/app/controllers/evaluators_controller.rb +++ b/app/controllers/evaluators_controller.rb @@ -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 diff --git a/app/models/evaluator.rb b/app/models/evaluator.rb index 1011a1e2..f4629f69 100644 --- a/app/models/evaluator.rb +++ b/app/models/evaluator.rb @@ -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 diff --git a/app/services/isolate.rb b/app/services/isolate.rb index 07478662..27c5e8ba 100644 --- a/app/services/isolate.rb +++ b/app/services/isolate.rb @@ -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 @@ -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: @@ -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? diff --git a/app/views/evaluators/_form.html.erb b/app/views/evaluators/_form.html.erb index 33f4d741..d6f4fb28 100644 --- a/app/views/evaluators/_form.html.erb +++ b/app/views/evaluators/_form.html.erb @@ -27,6 +27,10 @@ <%= f.label :language_id %>
<%= f.select :language_id, grouped_options_for_select(Language.grouped_submission_options, @evaluator.language_id), :include_blank => true %> +
+ <%= f.label :interactive_processes %>
+ <%= f.text_field :interactive_processes %> +
<%= f.label :owner_id %>
<% if policy(@evaluator).transfer? %> diff --git a/app/views/evaluators/show.html.erb b/app/views/evaluators/show.html.erb index d91657de..3835fda5 100644 --- a/app/views/evaluators/show.html.erb +++ b/app/views/evaluators/show.html.erb @@ -10,6 +10,8 @@

Language: <%= @evaluator.language&.name %>
+ Interactive processes: + <%= @evaluator.interactive_processes %>
Source: <%= predisplay @evaluator.source, language: @evaluator.language&.lexer %>

diff --git a/app/workers/judge_submission_worker.rb b/app/workers/judge_submission_worker.rb index febf86e8..4ed5ad88 100644 --- a/app/workers/judge_submission_worker.rb +++ b/app/workers/judge_submission_worker.rb @@ -135,7 +135,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 @@ -155,18 +155,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) @@ -178,15 +182,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? @@ -255,6 +263,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 + 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 diff --git a/db/migrate/20230225064438_add_interactive_processes_to_evaluators.rb b/db/migrate/20230225064438_add_interactive_processes_to_evaluators.rb new file mode 100644 index 00000000..fdaa86fc --- /dev/null +++ b/db/migrate/20230225064438_add_interactive_processes_to_evaluators.rb @@ -0,0 +1,5 @@ +class AddInteractiveProcessesToEvaluators < ActiveRecord::Migration + def change + add_column :evaluators, :interactive_processes, :integer, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 0c980263..cb0af18d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" @@ -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|