rbx.cp 0.5.53__py3-none-any.whl → 0.5.55__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. rbx/box/checkers.py +24 -4
  2. rbx/box/cli.py +8 -0
  3. rbx/box/contest/schema.py +53 -4
  4. rbx/box/naming.py +20 -5
  5. rbx/box/packaging/boca/upload.py +247 -0
  6. rbx/box/packaging/main.py +13 -1
  7. rbx/box/solutions.py +12 -1
  8. rbx/box/tasks.py +4 -2
  9. rbx/box/testcase_extractors.py +3 -0
  10. rbx/box/ui/captured_log.py +13 -8
  11. rbx/box/ui/css/app.tcss +47 -8
  12. rbx/box/ui/main.py +5 -1
  13. rbx/box/ui/screens/__init__.py +0 -0
  14. rbx/box/ui/screens/build.py +6 -0
  15. rbx/box/ui/screens/command.py +35 -0
  16. rbx/box/ui/{run.py → screens/run.py} +10 -38
  17. rbx/box/ui/screens/run_explorer.py +5 -0
  18. rbx/box/ui/screens/test_explorer.py +100 -0
  19. rbx/box/ui/widgets/file_log.py +63 -0
  20. rbx/box/ui/widgets/rich_log_box.py +5 -0
  21. rbx/grading/judge/sandboxes/stupid_sandbox.py +5 -1
  22. rbx/grading/judge/sandboxes/timeit.py +2 -1
  23. rbx/grading/processing_context.py +43 -4
  24. rbx/grading/steps.py +58 -14
  25. rbx/resources/packagers/boca/interactive/c +8 -1
  26. rbx/resources/packagers/boca/interactive/cc +8 -1
  27. rbx/resources/packagers/boca/interactive/cpp +8 -1
  28. rbx/resources/packagers/boca/interactive/java +8 -1
  29. rbx/resources/packagers/boca/interactive/kt +8 -1
  30. rbx/resources/packagers/boca/interactive/py2 +8 -1
  31. rbx/resources/packagers/boca/interactive/py3 +8 -1
  32. {rbx_cp-0.5.53.dist-info → rbx_cp-0.5.55.dist-info}/METADATA +8 -2
  33. {rbx_cp-0.5.53.dist-info → rbx_cp-0.5.55.dist-info}/RECORD +36 -28
  34. {rbx_cp-0.5.53.dist-info → rbx_cp-0.5.55.dist-info}/LICENSE +0 -0
  35. {rbx_cp-0.5.53.dist-info → rbx_cp-0.5.55.dist-info}/WHEEL +0 -0
  36. {rbx_cp-0.5.53.dist-info → rbx_cp-0.5.55.dist-info}/entry_points.txt +0 -0
rbx/box/ui/main.py CHANGED
@@ -5,10 +5,14 @@ from textual.containers import Center
5
5
  from textual.screen import Screen
6
6
  from textual.widgets import Footer, Header, OptionList
7
7
 
8
- from rbx.box.ui.run import RunScreen
8
+ from rbx.box.ui.screens.build import BuildScreen
9
+ from rbx.box.ui.screens.run import RunScreen
10
+ from rbx.box.ui.screens.test_explorer import TestExplorerScreen
9
11
 
10
12
  SCREEN_OPTIONS = [
11
13
  ('Run solutions against define testsets.', RunScreen),
14
+ ('Build tests.', BuildScreen),
15
+ ('Explore tests.', TestExplorerScreen),
12
16
  ]
13
17
 
14
18
 
File without changes
@@ -0,0 +1,6 @@
1
+ from rbx.box.ui.screens.command import CommandScreen
2
+
3
+
4
+ class BuildScreen(CommandScreen):
5
+ def __init__(self):
6
+ super().__init__(['rbx', 'build'])
@@ -0,0 +1,35 @@
1
+ import asyncio
2
+ from typing import List
3
+
4
+ from textual.app import ComposeResult
5
+ from textual.screen import Screen
6
+ from textual.widgets import Footer, Header
7
+
8
+ from rbx.box.ui.captured_log import LogDisplay
9
+
10
+
11
+ class CommandScreen(Screen):
12
+ BINDINGS = [('q', 'app.pop_screen', 'Back')]
13
+
14
+ def __init__(self, command: List[str]):
15
+ super().__init__()
16
+ self.command = command
17
+
18
+ def compose(self) -> ComposeResult:
19
+ yield Header()
20
+ yield Footer()
21
+ yield LogDisplay()
22
+
23
+ async def _run_command(self):
24
+ exitcode = await self.query_one(LogDisplay).capture(self.command)
25
+ if exitcode != 0:
26
+ self.query_one(LogDisplay).border_subtitle = f'Exit code: {exitcode}'
27
+ return
28
+
29
+ self.query_one(LogDisplay).border_subtitle = 'Finished'
30
+
31
+ async def on_mount(self):
32
+ self.query_one(LogDisplay).border_title = 'Command output'
33
+
34
+ # Fire and forget.
35
+ asyncio.create_task(self._run_command())
@@ -18,9 +18,10 @@ from rbx.box.solutions import (
18
18
  SolutionReportSkeleton,
19
19
  get_evals_formatted_time,
20
20
  get_testcase_markup_verdict,
21
- run_solutions,
22
21
  )
23
22
  from rbx.box.ui.captured_log import LogDisplay, LogDisplayState
23
+ from rbx.box.ui.screens.command import CommandScreen
24
+ from rbx.grading.steps import Evaluation
24
25
 
25
26
 
26
27
  def _build_solution_selection_label(sol: Solution) -> Text:
@@ -36,7 +37,7 @@ def _build_solution_selection_label(sol: Solution) -> Text:
36
37
  class SolutionReportScreen(Screen):
37
38
  skeleton: SolutionReportSkeleton
38
39
 
39
- BINDINGS = [('q', 'app.pop_screen', 'Quit')]
40
+ BINDINGS = [('q', 'app.pop_screen', 'Back')]
40
41
 
41
42
  def __init__(
42
43
  self,
@@ -86,7 +87,7 @@ class SolutionReportScreen(Screen):
86
87
  return i
87
88
  raise
88
89
 
89
- async def process(self, item: EvaluationItem):
90
+ async def process(self, item: EvaluationItem, eval: Evaluation):
90
91
  pkg = package.find_problem_package_or_die()
91
92
  sol_idx_in_skeleton = self._find_solution_index_in_skeleton(
92
93
  pkg.solutions[item.solution_index]
@@ -101,17 +102,19 @@ class SolutionReportScreen(Screen):
101
102
 
102
103
  table.update_cell_at(
103
104
  Coordinate(row=row_idx, column=2),
104
- get_testcase_markup_verdict(await item.eval()),
105
+ get_testcase_markup_verdict(eval),
105
106
  update_width=True,
106
107
  )
107
108
  table.update_cell_at(
108
109
  Coordinate(row=row_idx, column=3),
109
- get_evals_formatted_time([await item.eval()]),
110
+ get_evals_formatted_time([eval]),
110
111
  update_width=True,
111
112
  )
112
113
 
113
114
 
114
115
  class RunScreen(Screen):
116
+ BINDINGS = [('q', 'app.pop_screen', 'Back')]
117
+
115
118
  def compose(self) -> ComposeResult:
116
119
  yield Header()
117
120
  yield Footer()
@@ -141,7 +144,6 @@ class RunScreen(Screen):
141
144
  id='run-config',
142
145
  )
143
146
  yield Button('Run')
144
- yield LogDisplay()
145
147
 
146
148
  def on_mount(self):
147
149
  sols = self.query_one('#run-sols', SelectionList)
@@ -160,7 +162,6 @@ class RunScreen(Screen):
160
162
  async def on_button_pressed(self, _: Button.Pressed):
161
163
  await self.action_run()
162
164
 
163
- @textual.work(thread=True)
164
165
  async def _run_solutions(self, tracked_solutions: Set[str], check: bool):
165
166
  main_solution = package.get_main_solution()
166
167
  if check and main_solution is None:
@@ -169,36 +170,7 @@ class RunScreen(Screen):
169
170
  )
170
171
  check = False
171
172
 
172
- async def build():
173
- return await self.query_one(LogDisplay).capture(['rbx', 'build'])
174
-
175
- exitcode = self.app.call_from_thread(build)
176
-
177
- if exitcode != 0:
178
- textual.log(f'early quit: {exitcode}')
179
- return
180
-
181
- textual.log('build finished ok, running solutions')
182
-
183
- res = run_solutions(tracked_solutions=tracked_solutions, check=check)
184
-
185
- async def mount_report_widget() -> SolutionReportScreen:
186
- # log_display_state = self.query_one(LogDisplay).export()
187
- log_display_state = None
188
- await self.app.push_screen(
189
- screen := SolutionReportScreen(
190
- res.skeleton, log_display_state=log_display_state
191
- )
192
- )
193
- return screen
194
-
195
- new_screen = await mount_report_widget()
196
-
197
- async def process_item(item: EvaluationItem):
198
- await new_screen.process(item)
199
-
200
- for item in res.items:
201
- self.app.call_from_thread(process_item, item)
173
+ self.app.switch_screen(CommandScreen(['rbx', 'run']))
202
174
 
203
175
  async def action_run(self):
204
176
  sols = self.query_one('#run-sols', SelectionList)
@@ -207,4 +179,4 @@ class RunScreen(Screen):
207
179
  tracked_solutions = set(str(sol) for sol in sols.selected)
208
180
  check = 'check' in config.selected
209
181
 
210
- self._run_solutions(tracked_solutions, check)
182
+ await self._run_solutions(tracked_solutions, check)
@@ -0,0 +1,5 @@
1
+ from textual.screen import Screen
2
+
3
+
4
+ class RunExplorerScreen(Screen):
5
+ pass
@@ -0,0 +1,100 @@
1
+ from typing import List, Optional
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.screen import Screen
6
+ from textual.widgets import Footer, Header, Label, ListItem, ListView, RichLog
7
+
8
+ from rbx.box.testcase_extractors import (
9
+ GenerationTestcaseEntry,
10
+ extract_generation_testcases_from_groups,
11
+ )
12
+ from rbx.box.ui.widgets.file_log import FileLog
13
+ from rbx.box.ui.widgets.rich_log_box import RichLogBox
14
+
15
+
16
+ class TestExplorerScreen(Screen):
17
+ BINDINGS = [
18
+ ('q', 'app.pop_screen', 'Quit'),
19
+ ('m', 'toggle_metadata', 'Toggle metadata'),
20
+ ]
21
+
22
+ def __init__(self):
23
+ super().__init__()
24
+ self._entries: List[GenerationTestcaseEntry] = []
25
+
26
+ def compose(self) -> ComposeResult:
27
+ yield Header()
28
+ yield Footer()
29
+ with Horizontal(id='test-explorer'):
30
+ with Vertical(id='test-list-container'):
31
+ yield ListView(id='test-list')
32
+ with Vertical(id='test-details'):
33
+ yield FileLog(id='test-input')
34
+ yield FileLog(id='test-output')
35
+ yield RichLogBox(id='test-metadata')
36
+
37
+ async def on_mount(self):
38
+ self.query_one('#test-list').border_title = 'Tests'
39
+ self.query_one('#test-input').border_title = 'Input'
40
+ self.query_one('#test-output').border_title = 'Output'
41
+
42
+ metadata = self.query_one('#test-metadata', RichLogBox)
43
+ metadata.display = False
44
+ metadata.border_title = 'Metadata'
45
+ metadata.wrap = True
46
+ metadata.markup = True
47
+ metadata.clear().write('No test selected')
48
+ await self._update_tests()
49
+
50
+ def action_toggle_metadata(self):
51
+ metadata = self.query_one('#test-metadata', RichLogBox)
52
+ metadata.display = not metadata.display
53
+
54
+ def _update_selected_test(self, index: Optional[int]):
55
+ input = self.query_one('#test-input', FileLog)
56
+ output = self.query_one('#test-output', FileLog)
57
+ metadata = self.query_one('#test-metadata', RichLog)
58
+
59
+ if index is None:
60
+ input.path = None
61
+ output.path = None
62
+ metadata.clear().write('No test selected')
63
+ return
64
+ entry = self._entries[index]
65
+ input.path = entry.metadata.copied_to.inputPath
66
+ output.path = entry.metadata.copied_to.outputPath
67
+
68
+ metadata.clear()
69
+ metadata.write(
70
+ f'[bold]{entry.group_entry.group}[/bold] / [bold]{entry.group_entry.index}[/bold]'
71
+ )
72
+ if entry.metadata.copied_from is not None:
73
+ metadata.write(
74
+ f'[bold]Copied from:[/bold] {entry.metadata.copied_from.inputPath}'
75
+ )
76
+ if entry.metadata.generator_call is not None:
77
+ metadata.write(f'[bold]Gen. call:[/bold] {entry.metadata.generator_call}')
78
+ if entry.metadata.generator_script is not None:
79
+ metadata.write(
80
+ f'[bold]Gen. script:[/bold] {entry.metadata.generator_script}'
81
+ )
82
+
83
+ async def _update_tests(self):
84
+ self.watch(
85
+ self.query_one('#test-list', ListView),
86
+ 'index',
87
+ self._update_selected_test,
88
+ )
89
+
90
+ self._entries = await extract_generation_testcases_from_groups()
91
+
92
+ test_names = [
93
+ f'{entry.group_entry.group}/{entry.group_entry.index}'
94
+ for entry in self._entries
95
+ ]
96
+
97
+ await self.query_one('#test-list', ListView).clear()
98
+ await self.query_one('#test-list', ListView).extend(
99
+ [ListItem(Label(name)) for name in test_names]
100
+ )
@@ -0,0 +1,63 @@
1
+ import pathlib
2
+ from typing import Optional
3
+
4
+ import aiofiles
5
+ from textual import work
6
+ from textual.app import ComposeResult
7
+ from textual.reactive import reactive
8
+ from textual.widget import Widget
9
+ from textual.widgets import Log
10
+
11
+ BATCH_SIZE = 1024
12
+
13
+
14
+ class FileLog(Widget, can_focus=False):
15
+ DEFAULT_CSS = """
16
+ FileLog {
17
+ border: solid $accent;
18
+ height: 1fr;
19
+ width: 1fr;
20
+ }
21
+ """
22
+
23
+ path: reactive[Optional[pathlib.Path]] = reactive(None)
24
+
25
+ def compose(self) -> ComposeResult:
26
+ yield Log()
27
+
28
+ def on_mount(self):
29
+ self.query_one(Log).auto_scroll = False
30
+ self.query_one(Log).can_focus = False
31
+
32
+ @work(exclusive=True)
33
+ async def _load_file(self, path: pathlib.Path):
34
+ log = self.query_one(Log)
35
+ log.clear()
36
+ path_str = str(path.relative_to(pathlib.Path.cwd()))
37
+ self.border_subtitle = f'{path_str} (loading...)'
38
+
39
+ async with aiofiles.open(path, 'r') as f:
40
+ batch = []
41
+ async for line in f:
42
+ batch.append(line)
43
+ if len(batch) >= BATCH_SIZE:
44
+ log.write(''.join(batch))
45
+ batch = []
46
+
47
+ if batch:
48
+ log.write(''.join(batch))
49
+
50
+ self.border_subtitle = path_str
51
+
52
+ async def watch_path(self, path: Optional[pathlib.Path]):
53
+ log = self.query_one(Log)
54
+ log.clear()
55
+
56
+ if path is None:
57
+ return
58
+
59
+ if not path.is_file():
60
+ self.query_one(Log).write(f'File {path} does not exist')
61
+ return
62
+
63
+ self._load_file(path)
@@ -0,0 +1,5 @@
1
+ from textual.widgets import RichLog
2
+
3
+
4
+ class RichLogBox(RichLog, can_focus=False):
5
+ pass
@@ -322,7 +322,11 @@ class StupidSandbox(SandboxBase):
322
322
  return self.translate_box_exitcode(self.returncode)
323
323
 
324
324
  def translate_box_exitcode(self, exitcode: int) -> bool:
325
- # SIGALRM can be safely ignored, just in case it leaks away.
325
+ # SIGALRM can be safely ignored, just in case it leaks away. SIGTERM also.
326
+ if self.log is None:
327
+ return False
328
+ if 'TE' in self.get_status_list():
329
+ return True
326
330
  return super().translate_box_exitcode(exitcode) or -exitcode == signal.SIGALRM
327
331
 
328
332
  def debug_message(self) -> Any:
@@ -109,7 +109,8 @@ def create_tee(files, mode, buffer_size=4096, prefix=''):
109
109
  else:
110
110
  # Parent -- Return a file object wrapper around the pipe to the
111
111
  # child.
112
- return os.fdopen(pipe_write, 'w', closefd=False)
112
+ # Preserve line buffering (buffering=1).
113
+ return os.fdopen(pipe_write, 'w', buffering=1, closefd=False)
113
114
 
114
115
 
115
116
  def parse_opts() -> Options:
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import contextlib
2
3
  import os
3
4
  import signal
@@ -5,22 +6,26 @@ import threading
5
6
  from typing import Optional, Set
6
7
 
7
8
  _processing_context_pids: Optional[Set[int]] = None
9
+ _terminate_all_on_error = False
8
10
  _lock = threading.Lock()
9
11
 
10
12
  # Creating a processing context is not thread-safe, but adding to it is.
11
13
 
12
14
 
13
15
  @contextlib.contextmanager
14
- def new_processing_context():
15
- global _processing_context_pids
16
+ def new_processing_context(terminate_all_on_error: bool = False):
17
+ global _processing_context_pids, _terminate_all_on_error
16
18
  with _lock:
17
19
  old_processing_context_pids = _processing_context_pids
20
+ _old_terminate_all_on_error = _terminate_all_on_error
18
21
  _processing_context_pids = set()
22
+ _terminate_all_on_error = terminate_all_on_error
19
23
  try:
20
24
  yield
21
25
  finally:
22
26
  with _lock:
23
27
  _processing_context_pids = old_processing_context_pids
28
+ _terminate_all_on_error = _old_terminate_all_on_error
24
29
 
25
30
 
26
31
  def get_processing_context() -> Set[int]:
@@ -36,7 +41,8 @@ def add_to_processing_context(pid: int):
36
41
  _processing_context_pids.add(pid)
37
42
 
38
43
 
39
- def terminate_all_processes_in_context():
44
+ def terminate_all_processes_in_context(clear: bool = True):
45
+ global _processing_context_pids
40
46
  with _lock:
41
47
  if _processing_context_pids is None:
42
48
  return
@@ -45,4 +51,37 @@ def terminate_all_processes_in_context():
45
51
  os.kill(pid, signal.SIGTERM)
46
52
  except OSError:
47
53
  pass
48
- _processing_context_pids.clear()
54
+ if clear:
55
+ _processing_context_pids.clear()
56
+
57
+
58
+ async def wait_all_processes_in_context(wait_for: int):
59
+ global _processing_context_pids, _terminate_all_on_error
60
+ wait_pids = set()
61
+ while len(get_processing_context()) < wait_for:
62
+ await asyncio.sleep(0.01)
63
+
64
+ with _lock:
65
+ if _processing_context_pids is None:
66
+ return
67
+ wait_pids.update(_processing_context_pids)
68
+
69
+ wait_lock = threading.Lock()
70
+ finished_pids = []
71
+
72
+ def process(pid: int, returncode: int):
73
+ with wait_lock:
74
+ finished_pids.append(pid)
75
+ if returncode != 0 and _terminate_all_on_error:
76
+ terminate_all_processes_in_context()
77
+
78
+ def wait_all_processes():
79
+ while len(finished_pids) < len(wait_pids):
80
+ try:
81
+ pid, status = os.wait()
82
+ except ChildProcessError:
83
+ return
84
+ if pid in wait_pids:
85
+ process(pid, os.waitstatus_to_exitcode(status))
86
+
87
+ await asyncio.to_thread(wait_all_processes)
rbx/grading/steps.py CHANGED
@@ -193,6 +193,11 @@ class RunLogMetadata(BaseModel):
193
193
  retryIndex: Optional[int] = None
194
194
 
195
195
 
196
+ class ProcessingContextLog(BaseModel):
197
+ pid: int = -1
198
+ exitindex: int = -1
199
+
200
+
196
201
  class RunLog(BaseModel):
197
202
  exitcode: int = 0
198
203
  exitstatus: str = SandboxBase.EXIT_SANDBOX_ERROR
@@ -330,14 +335,29 @@ def _expand_part(part: str, sandbox: SandboxBase) -> List[str]:
330
335
  return [part]
331
336
 
332
337
 
338
+ def _get_java_memory_limits(sandbox: SandboxBase) -> Tuple[int, int]:
339
+ max_memory = sandbox.params.address_space
340
+ if max_memory is None:
341
+ max_memory = 2048
342
+ return max_memory, min(512, int(max_memory * 0.9))
343
+
344
+
333
345
  def _split_and_expand(command: str, sandbox: SandboxBase) -> List[str]:
334
346
  res = []
335
- parts = shlex.split(command.format(memory=sandbox.params.address_space or 2048))
347
+ max_mem, init_mem = _get_java_memory_limits(sandbox)
348
+ parts = shlex.split(command.format(memory=max_mem, initialMemory=init_mem))
336
349
  for part in parts:
337
350
  res.extend(_expand_part(part, sandbox))
338
351
  return res
339
352
 
340
353
 
354
+ def get_exe_from_command(command: str) -> str:
355
+ cmds = shlex.split(command)
356
+ if not cmds:
357
+ return command
358
+ return cmds[0]
359
+
360
+
341
361
  def _is_c_command(exe_command: str) -> bool:
342
362
  return 'gcc' in exe_command or 'clang' in exe_command
343
363
 
@@ -351,15 +371,26 @@ def is_cxx_command(exe_command: str) -> bool:
351
371
 
352
372
 
353
373
  def is_cxx_sanitizer_command(command: str) -> bool:
354
- cmds = shlex.split(command)
355
- if not cmds:
374
+ exe = get_exe_from_command(command)
375
+ if not exe:
356
376
  return False
357
- exe = cmds[0]
358
377
  if not is_cxx_command(exe):
359
378
  return False
360
379
  return 'fsanitize' in command
361
380
 
362
381
 
382
+ def is_java_command(exe_command: str) -> bool:
383
+ return 'javac' in exe_command or 'java' in exe_command
384
+
385
+
386
+ def is_kotlin_command(exe_command: str) -> bool:
387
+ return 'kotlinc' in exe_command or 'kotlin' in exe_command
388
+
389
+
390
+ def is_java_like_command(exe_command: str) -> bool:
391
+ return is_java_command(exe_command) or is_kotlin_command(exe_command)
392
+
393
+
363
394
  @functools.cache
364
395
  def _complain_about_clang() -> None:
365
396
  console.print(
@@ -539,6 +570,10 @@ def compile(
539
570
  stderr_file = pathlib.PosixPath(f'compile-{i}.stderr')
540
571
  sandbox.params.set_stdall(stdout=stdout_file, stderr=stderr_file)
541
572
 
573
+ # Remove memory constraints for Java.
574
+ if is_java_like_command(get_exe_from_command(command)):
575
+ sandbox.params.address_space = None
576
+
542
577
  if bits_artifact is not None and _is_cpp_command(cmd[0]):
543
578
  # Include from sandbox directory to import bits/stdc++.h.
544
579
  cmd.append('-I.')
@@ -604,6 +639,10 @@ async def run(
604
639
  cmd = _split_and_expand(command, sandbox)
605
640
  sandbox.set_params(params)
606
641
 
642
+ # Remove memory constraints for Java.
643
+ if is_java_like_command(get_exe_from_command(command)):
644
+ sandbox.params.address_space = None
645
+
607
646
  if not await asyncio.to_thread(sandbox.execute_without_std, cmd):
608
647
  console.print(
609
648
  '[error]Sandbox crashed while processing command:[/error]',
@@ -614,7 +653,7 @@ async def run(
614
653
  return None
615
654
 
616
655
  if sandbox.get_exit_code() != 0 and kill_on_processing_context_exit:
617
- processing_context.terminate_all_processes_in_context()
656
+ processing_context.terminate_all_processes_in_context(clear=False)
618
657
 
619
658
  if not _process_output_artifacts(artifacts, sandbox):
620
659
  return None
@@ -657,22 +696,27 @@ async def run_coordinated(
657
696
  interactor: CoordinatedRunParams,
658
697
  solution: CoordinatedRunParams,
659
698
  ) -> Tuple[Optional[RunLog], Optional[RunLog]]:
660
- with processing_context.new_processing_context():
699
+ with processing_context.new_processing_context(terminate_all_on_error=True):
700
+ # Schedule both runs to execute immediately.
661
701
  runs = tuple(
662
- run(
663
- params.command,
664
- params.params,
665
- params.sandbox,
666
- params.artifacts,
667
- params.metadata,
668
- kill_on_processing_context_exit=True,
702
+ asyncio.create_task(
703
+ run(
704
+ params.command,
705
+ params.params,
706
+ params.sandbox,
707
+ params.artifacts,
708
+ params.metadata,
709
+ kill_on_processing_context_exit=True,
710
+ )
669
711
  )
670
712
  for params in [interactor, solution]
671
713
  )
672
- return typing.cast(
714
+ await processing_context.wait_all_processes_in_context(wait_for=2)
715
+ logs = typing.cast(
673
716
  Tuple[Optional[RunLog], Optional[RunLog]],
674
717
  tuple(await asyncio.gather(*runs)),
675
718
  )
719
+ return logs
676
720
 
677
721
 
678
722
  def _normalize_checked_words(s: str) -> Tuple[str, ...]:
@@ -146,8 +146,15 @@ echo "solution \$SFPID -> \$ECSF" >&2
146
146
  echo "interactor exitcode \$ECINT" >&2
147
147
  echo "solution exitcode \$ECSF" >&2
148
148
 
149
+ RTE=0
150
+ if [[ \$ECSF -eq -13 ]]; then
151
+ RTE=0
152
+ elif [[ \$ECSF -ne 0 ]] && cat stderr0 | grep -q "wrong output format Unexpected end of file"; then
153
+ RTE=1
154
+ fi
155
+
149
156
  ret=0
150
- if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]]; then
157
+ if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]] && [[ \$RTE -eq 0 ]]; then
151
158
  echo "testlib exitcode \$ECINT" >stdout0
152
159
  ret=0
153
160
  elif [[ \$ECSF -ne 0 ]]; then
@@ -146,8 +146,15 @@ echo "solution \$SFPID -> \$ECSF" >&2
146
146
  echo "interactor exitcode \$ECINT" >&2
147
147
  echo "solution exitcode \$ECSF" >&2
148
148
 
149
+ RTE=0
150
+ if [[ \$ECSF -eq -13 ]]; then
151
+ RTE=0
152
+ elif [[ \$ECSF -ne 0 ]] && cat stderr0 | grep -q "wrong output format Unexpected end of file"; then
153
+ RTE=1
154
+ fi
155
+
149
156
  ret=0
150
- if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]]; then
157
+ if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]] && [[ \$RTE -eq 0 ]]; then
151
158
  echo "testlib exitcode \$ECINT" >stdout0
152
159
  ret=0
153
160
  elif [[ \$ECSF -ne 0 ]]; then
@@ -146,8 +146,15 @@ echo "solution \$SFPID -> \$ECSF" >&2
146
146
  echo "interactor exitcode \$ECINT" >&2
147
147
  echo "solution exitcode \$ECSF" >&2
148
148
 
149
+ RTE=0
150
+ if [[ \$ECSF -eq -13 ]]; then
151
+ RTE=0
152
+ elif [[ \$ECSF -ne 0 ]] && cat stderr0 | grep -q "wrong output format Unexpected end of file"; then
153
+ RTE=1
154
+ fi
155
+
149
156
  ret=0
150
- if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]]; then
157
+ if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]] && [[ \$RTE -eq 0 ]]; then
151
158
  echo "testlib exitcode \$ECINT" >stdout0
152
159
  ret=0
153
160
  elif [[ \$ECSF -ne 0 ]]; then
@@ -159,8 +159,15 @@ echo "solution \$SFPID -> \$ECSF" >&2
159
159
  echo "interactor exitcode \$ECINT" >&2
160
160
  echo "solution exitcode \$ECSF" >&2
161
161
 
162
+ RTE=0
163
+ if [[ \$ECSF -eq -13 ]]; then
164
+ RTE=0
165
+ elif [[ \$ECSF -ne 0 ]] && cat stderr0 | grep -q "wrong output format Unexpected end of file"; then
166
+ RTE=1
167
+ fi
168
+
162
169
  ret=0
163
- if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]]; then
170
+ if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]] && [[ \$RTE -eq 0 ]]; then
164
171
  echo "testlib exitcode \$ECINT" >stdout0
165
172
  ret=0
166
173
  elif [[ \$ECSF -ne 0 ]]; then
@@ -150,8 +150,15 @@ echo "solution \$SFPID -> \$ECSF" >&2
150
150
  echo "interactor exitcode \$ECINT" >&2
151
151
  echo "solution exitcode \$ECSF" >&2
152
152
 
153
+ RTE=0
154
+ if [[ \$ECSF -eq -13 ]]; then
155
+ RTE=0
156
+ elif [[ \$ECSF -ne 0 ]] && cat stderr0 | grep -q "wrong output format Unexpected end of file"; then
157
+ RTE=1
158
+ fi
159
+
153
160
  ret=0
154
- if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]]; then
161
+ if [[ \$ECINT -ge 1 ]] && [[ \$ECINT -le 4 ]] && [[ \$RTE -eq 0 ]]; then
155
162
  echo "testlib exitcode \$ECINT" >stdout0
156
163
  ret=0
157
164
  elif [[ \$ECSF -ne 0 ]]; then