rbx.cp 0.5.73__py3-none-any.whl → 0.6.1__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 (86) hide show
  1. rbx/annotations.py +21 -1
  2. rbx/box/cd.py +11 -1
  3. rbx/box/checkers.py +9 -1
  4. rbx/box/cli.py +59 -46
  5. rbx/box/code.py +142 -3
  6. rbx/box/contest/build_contest_statements.py +44 -34
  7. rbx/box/contest/contest_package.py +4 -7
  8. rbx/box/contest/main.py +7 -58
  9. rbx/box/contest/schema.py +52 -8
  10. rbx/box/contest/statements.py +53 -25
  11. rbx/box/creation.py +3 -36
  12. rbx/box/environment.py +21 -9
  13. rbx/box/fields.py +35 -0
  14. rbx/box/lang.py +27 -0
  15. rbx/box/linting.py +26 -0
  16. rbx/box/package.py +4 -35
  17. rbx/box/packaging/boca/packager.py +48 -5
  18. rbx/box/packaging/contest_main.py +13 -0
  19. rbx/box/packaging/main.py +13 -2
  20. rbx/box/packaging/packager.py +4 -4
  21. rbx/box/packaging/pkg/packager.py +142 -0
  22. rbx/box/packaging/polygon/packager.py +2 -24
  23. rbx/box/packaging/polygon/upload.py +35 -17
  24. rbx/box/presets/__init__.py +362 -281
  25. rbx/box/presets/lock_schema.py +1 -2
  26. rbx/box/presets/schema.py +13 -5
  27. rbx/box/remote.py +2 -2
  28. rbx/box/retries.py +8 -0
  29. rbx/box/schema.py +82 -19
  30. rbx/box/solutions.py +77 -15
  31. rbx/box/statements/build_statements.py +44 -27
  32. rbx/box/statements/builders.py +18 -10
  33. rbx/box/statements/expander.py +49 -0
  34. rbx/box/statements/latex_jinja.py +61 -4
  35. rbx/box/statements/schema.py +33 -9
  36. rbx/box/stats.py +92 -0
  37. rbx/box/tasks.py +6 -3
  38. rbx/box/testcase_utils.py +19 -47
  39. rbx/box/tooling/__init__.py +0 -0
  40. rbx/box/tooling/boca/__init__.py +0 -0
  41. rbx/box/tooling/boca/main.py +13 -0
  42. rbx/box/tooling/boca/scrape.py +34 -0
  43. rbx/box/{packaging/boca/upload.py → tooling/boca/scraper.py} +77 -8
  44. rbx/box/tooling/main.py +8 -0
  45. rbx/box/ui/utils/run_ui.py +1 -1
  46. rbx/box/ui/widgets/interaction_box.py +19 -1
  47. rbx/grading/caching.py +18 -2
  48. rbx/grading/judge/sandbox.py +60 -5
  49. rbx/grading/judge/sandboxes/isolate.py +1 -0
  50. rbx/grading/judge/sandboxes/stupid_sandbox.py +11 -5
  51. rbx/grading/judge/sandboxes/timeit.py +36 -15
  52. rbx/grading/processing_context.py +62 -78
  53. rbx/grading/steps.py +92 -40
  54. rbx/resources/packagers/boca/checker.sh +4 -1
  55. rbx/resources/packagers/boca/compile/c +2 -6
  56. rbx/resources/packagers/boca/compile/cc +2 -6
  57. rbx/resources/packagers/boca/compile/cpp +2 -6
  58. rbx/resources/packagers/boca/compile/java +1 -6
  59. rbx/resources/packagers/boca/compile/kt +24 -28
  60. rbx/resources/packagers/boca/compile/py2 +2 -6
  61. rbx/resources/packagers/boca/compile/py3 +2 -6
  62. rbx/resources/packagers/boca/interactive/c +15 -83
  63. rbx/resources/packagers/boca/interactive/cc +15 -83
  64. rbx/resources/packagers/boca/interactive/cpp +15 -83
  65. rbx/resources/packagers/boca/interactive/java +15 -88
  66. rbx/resources/packagers/boca/interactive/kt +15 -88
  67. rbx/resources/packagers/boca/interactive/py2 +15 -88
  68. rbx/resources/packagers/boca/interactive/py3 +15 -88
  69. rbx/resources/packagers/boca/interactor_compile.sh +5 -2
  70. rbx/resources/packagers/boca/interactor_run.sh +174 -0
  71. rbx/resources/packagers/boca/safeexec.c +530 -0
  72. rbx/resources/packagers/boca/safeexec_compile.sh +49 -0
  73. rbx/resources/presets/default/contest/contest.rbx.yml +9 -8
  74. rbx/resources/presets/default/problem/problem.rbx.yml +38 -26
  75. rbx/resources/presets/default/problem/random.txt +3 -1
  76. rbx/resources/presets/default/problem/rbx.h +92 -0
  77. rbx/resources/presets/default/problem/statement/statement.rbx.tex +4 -7
  78. rbx/resources/presets/default/problem/validator.cpp +8 -8
  79. rbx/resources/templates/rbx.h +2 -3
  80. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.1.dist-info}/METADATA +23 -6
  81. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.1.dist-info}/RECORD +84 -71
  82. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.1.dist-info}/WHEEL +1 -1
  83. rbx/resources/packagers/boca/compile/pas +0 -172
  84. rbx/resources/presets/default/problem/statement/projecao.png +0 -0
  85. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.1.dist-info}/LICENSE +0 -0
  86. {rbx_cp-0.5.73.dist-info → rbx_cp-0.6.1.dist-info}/entry_points.txt +0 -0
@@ -6,15 +6,17 @@ import pathlib
6
6
  import re
7
7
  import shutil
8
8
  import typing
9
- from typing import Any, NoReturn, Optional, Tuple
9
+ from typing import Any, List, NoReturn, Optional, Tuple
10
10
 
11
11
  import dateparser
12
12
  import mechanize
13
13
  import typer
14
14
  from bs4 import BeautifulSoup
15
+ from pydantic import BaseModel
15
16
 
16
17
  from rbx import console
17
18
  from rbx.box import naming
19
+ from rbx.grading.steps import Outcome
18
20
 
19
21
  ALERT_REGEX = re.compile(r'\<script[^\>]*\>\s*alert\(\'([^\']+)\'\);?\s*\<\/script\>')
20
22
  UPLOAD_LOG_REGEX = re.compile(r'Problem (\d+) \([^\)]+\) updated')
@@ -32,7 +34,30 @@ def _parse_env_var(var: str, override: Optional[str]) -> str:
32
34
  return value
33
35
 
34
36
 
35
- class BocaUploader:
37
+ def _parse_answer_as_outcome(answer: str) -> Optional[Outcome]:
38
+ answer = answer.lower()
39
+ if 'yes' in answer:
40
+ return Outcome.ACCEPTED
41
+ if 'wrong answer' in answer:
42
+ return Outcome.WRONG_ANSWER
43
+ if 'time limit exceeded' in answer:
44
+ return Outcome.TIME_LIMIT_EXCEEDED
45
+ if 'runtime error' in answer:
46
+ return Outcome.RUNTIME_ERROR
47
+ return None
48
+
49
+
50
+ class BocaRun(BaseModel):
51
+ run_number: int
52
+ site_number: int
53
+ problem_shortname: str
54
+ outcome: Outcome
55
+ time: int
56
+
57
+ user: Optional[str] = None
58
+
59
+
60
+ class BocaScraper:
36
61
  def __init__(
37
62
  self,
38
63
  base_url: Optional[str] = None,
@@ -256,7 +281,49 @@ class BocaUploader:
256
281
  )
257
282
  raise typer.Exit(1)
258
283
 
259
- def download_run(self, run_number: int, site_number: int, into_dir: pathlib.Path):
284
+ def list_runs(self) -> List[BocaRun]:
285
+ _, html = self.open(
286
+ f'{self.base_url}/admin/run.php',
287
+ error_msg='Error while listing runs in BOCA',
288
+ )
289
+
290
+ soup = BeautifulSoup(html, 'html.parser')
291
+ rows = soup.select('form[name="form1"] table tr')
292
+
293
+ runs: List[BocaRun] = []
294
+ for row in rows[1:]:
295
+ cells = row.select('td')
296
+
297
+ run_number = cells[0].text.strip()
298
+ site_number = cells[1].text.strip()
299
+ shortname = cells[4].text.strip()
300
+ answer = cells[-1].text.strip()
301
+ time = int(cells[3].text.strip())
302
+ user = cells[2].text.strip()
303
+
304
+ outcome = _parse_answer_as_outcome(answer)
305
+ if outcome is None:
306
+ continue
307
+ runs.append(
308
+ BocaRun(
309
+ run_number=run_number,
310
+ site_number=site_number,
311
+ problem_shortname=shortname,
312
+ outcome=outcome,
313
+ time=time,
314
+ user=user,
315
+ )
316
+ )
317
+
318
+ return runs
319
+
320
+ def download_run(
321
+ self,
322
+ run_number: int,
323
+ site_number: int,
324
+ into_dir: pathlib.Path,
325
+ name: Optional[str] = None,
326
+ ):
260
327
  url = f'{self.base_url}/admin/runedit.php?runnumber={run_number}&runsitenumber={site_number}'
261
328
  _, html = self.open(
262
329
  url,
@@ -277,7 +344,8 @@ class BocaUploader:
277
344
  if link_col is None:
278
345
  continue
279
346
  href = str(link_col.attrs['href'])
280
- filename = pathlib.Path(link_col.text.strip())
347
+ if filename is None:
348
+ filename = pathlib.Path(link_col.text.strip())
281
349
  break
282
350
 
283
351
  if href is None or filename is None:
@@ -289,16 +357,17 @@ class BocaUploader:
289
357
  tmp_file, _ = self.br.retrieve(link.absolute_url)
290
358
  if tmp_file is None:
291
359
  self.raw_error('Error while downloading run:\nDownloaded file is None.')
292
- final_path = into_dir / filename.with_stem(f'{run_number}-{site_number}')
360
+ filename = filename.with_stem(name or f'{run_number}-{site_number}')
361
+ final_path = into_dir / filename
293
362
  final_path.parent.mkdir(parents=True, exist_ok=True)
294
363
  shutil.move(tmp_file, final_path)
295
364
  return final_path
296
365
 
297
366
 
298
367
  @functools.lru_cache
299
- def get_boca_uploader(
368
+ def get_boca_scraper(
300
369
  base_url: Optional[str] = None,
301
370
  username: Optional[str] = None,
302
371
  password: Optional[str] = None,
303
- ) -> BocaUploader:
304
- return BocaUploader(base_url, username, password)
372
+ ) -> BocaScraper:
373
+ return BocaScraper(base_url, username, password)
@@ -0,0 +1,8 @@
1
+ import typer
2
+
3
+ from rbx import annotations
4
+ from rbx.box.tooling.boca import main as boca_main
5
+
6
+ app = typer.Typer(no_args_is_help=True, cls=annotations.AliasGroup)
7
+
8
+ app.add_typer(boca_main.app, name='boca')
@@ -52,7 +52,7 @@ def get_solution_markup(
52
52
 
53
53
  evals = get_solution_evals_or_null(skeleton, solution)
54
54
  report = solutions.get_solution_outcome_report(
55
- solution, evals or [], skeleton.verification, subset=False
55
+ solution, skeleton, evals or [], skeleton.verification, subset=False
56
56
  )
57
57
  if evals is None:
58
58
  return header + '\n' + report.get_verdict_markup(incomplete=True)
@@ -28,13 +28,31 @@ class InteractionBox(RichLogBox):
28
28
  self.auto_scroll = False
29
29
  self.can_focus = False
30
30
 
31
+ def _show_raw_text(self, path: pathlib.Path):
32
+ path_str = str(path.relative_to(pathlib.Path.cwd()))
33
+ self.write(
34
+ rich.text.Text(
35
+ 'Showing raw interaction file because the interaction text is not parseable.\n'
36
+ 'This might usually happen when the processes do not communicate properly.\n',
37
+ style='red',
38
+ )
39
+ )
40
+ self.write(rich.text.Text(path.read_text()))
41
+ self.border_subtitle = f'{path_str} (raw file)'
42
+
31
43
  @work(exclusive=True)
32
44
  async def _load_file(self, path: pathlib.Path):
33
45
  self.clear()
34
46
  path_str = str(path.relative_to(pathlib.Path.cwd()))
35
47
  self.border_subtitle = f'{path_str} (loading...)'
36
48
 
37
- interaction = await asyncio.to_thread(testcase_utils.parse_interaction, path)
49
+ try:
50
+ interaction = await asyncio.to_thread(
51
+ testcase_utils.parse_interaction, path
52
+ )
53
+ except testcase_utils.TestcaseInteractionParsingError:
54
+ self._show_raw_text(path)
55
+ return
38
56
 
39
57
  for entry in interaction.entries:
40
58
  if entry.pipe == 0:
rbx/grading/caching.py CHANGED
@@ -7,10 +7,13 @@ from typing import Any, Dict, List, Optional
7
7
 
8
8
  from pydantic import BaseModel
9
9
 
10
+ from rbx import console
10
11
  from rbx.grading.judge.digester import digest_cooperatively
11
12
  from rbx.grading.judge.storage import Storage, copyfileobj
12
13
  from rbx.grading.steps import DigestHolder, GradingArtifacts, GradingLogsHolder
13
14
 
15
+ VERBOSE = False
16
+
14
17
 
15
18
  class CacheInput(BaseModel):
16
19
  """
@@ -157,12 +160,21 @@ def _build_cache_input(
157
160
  artifacts.model_copy(deep=True) for artifacts in artifact_list
158
161
  ]
159
162
  for artifacts in cloned_artifact_list:
163
+ # Clear logs from cache input, since they are not
164
+ # part of the cache key.
165
+ artifacts.logs = None
166
+
160
167
  for output in artifacts.outputs:
161
168
  if output.hash:
162
169
  # Cleanup dest field from hash artifacts
163
170
  # since they only their digest value should
164
171
  # be tracked by cache.
165
172
  output.dest = None
173
+
174
+ if output.digest is not None:
175
+ # Cleanup output digest value from cache input,
176
+ # since it is not part of the cache key.
177
+ output.digest.value = None
166
178
  return CacheInput(
167
179
  commands=commands, artifacts=cloned_artifact_list, extra_params=extra_params
168
180
  )
@@ -237,7 +249,11 @@ class DependencyCacheBlock:
237
249
  artifact_list=self.artifact_list,
238
250
  extra_params=self.extra_params,
239
251
  )
252
+ if VERBOSE:
253
+ console.console.log(f'Cache input is: {input}')
240
254
  self._key = _build_cache_key(input)
255
+ if VERBOSE:
256
+ console.console.log(f'Cache key is: {self._key}')
241
257
  found = self.cache.find_in_cache(
242
258
  self.commands, self.artifact_list, self.extra_params, key=self._key
243
259
  )
@@ -349,8 +365,8 @@ class DependencyCache:
349
365
  extra_params: Dict[str, Any],
350
366
  key: Optional[str] = None,
351
367
  ):
352
- input = CacheInput(
353
- commands=commands, artifacts=artifact_list, extra_params=extra_params
368
+ input = _build_cache_input(
369
+ commands=commands, artifact_list=artifact_list, extra_params=extra_params
354
370
  )
355
371
  key = key or _build_cache_key(input)
356
372
 
@@ -1,4 +1,5 @@
1
1
  import abc
2
+ import asyncio
2
3
  import collections
3
4
  import dataclasses
4
5
  import io
@@ -15,7 +16,6 @@ from typing import IO, Any, Dict, List, Optional
15
16
 
16
17
  import pydantic
17
18
 
18
- from rbx.grading import processing_context
19
19
  from rbx.grading.judge import cacher, storage
20
20
 
21
21
  logger = logging.getLogger(__name__)
@@ -23,6 +23,33 @@ logger = logging.getLogger(__name__)
23
23
  MERGE_STDERR = pathlib.PosixPath('/dev/stdout')
24
24
 
25
25
 
26
+ # Thread-safe version of asyncio.Event.
27
+ class Event_ts(asyncio.Event):
28
+ def __init__(self):
29
+ super().__init__()
30
+ self._inherited_loop = asyncio.get_event_loop()
31
+
32
+ def get_loop(self):
33
+ if self._inherited_loop is None:
34
+ return None
35
+ if self._inherited_loop.is_closed():
36
+ return None
37
+ return self._inherited_loop
38
+
39
+ def set_loop(self, loop):
40
+ self._inherited_loop = loop
41
+
42
+ def set(self):
43
+ loop = self.get_loop()
44
+ if loop is not None:
45
+ loop.call_soon_threadsafe(super().set)
46
+
47
+ def clear(self):
48
+ loop = self.get_loop()
49
+ if loop is not None:
50
+ loop.call_soon_threadsafe(super().clear)
51
+
52
+
26
53
  def wait_without_std(
27
54
  procs: List[subprocess.Popen], actually_pipe_to_stdout: bool = False
28
55
  ) -> List[int]:
@@ -116,6 +143,7 @@ class SandboxParams(pydantic.BaseModel):
116
143
  wallclock_timeout: Optional[int] = None # ms
117
144
  extra_timeout: Optional[int] = None # ms
118
145
  reverse_io: bool = False
146
+ pgid: Optional[int] = None
119
147
 
120
148
  # For timeit
121
149
  timeit_dups: Dict[str, List[pathlib.Path]] = dataclasses.field(
@@ -229,6 +257,7 @@ class SandboxBase(abc.ABC):
229
257
 
230
258
  self.params = params or SandboxParams()
231
259
  self.pid = None
260
+ self.pid_event = Event_ts()
232
261
 
233
262
  # Set common environment variables.
234
263
  # Specifically needed by Python, that searches the home for
@@ -329,18 +358,35 @@ class SandboxBase(abc.ABC):
329
358
  pass
330
359
 
331
360
  def set_pid(self, pid: int):
332
- processing_context.add_to_processing_context(pid)
361
+ """Set the PID of the sandboxed process.
362
+
363
+ pid (int): the PID of the sandboxed process.
364
+
365
+ """
333
366
  self.pid = pid
367
+ self.pid_event.set()
334
368
 
335
- def get_pid(self) -> Optional[int]:
369
+ async def get_pid(self) -> int:
336
370
  """Return the PID of the sandboxed process.
337
371
 
338
- return (int|None): the PID of the sandboxed process, or None if
339
- the sandboxed process is not running.
372
+ Blocks until the PID is set.
373
+
374
+ return (int): the PID of the sandboxed process.
340
375
 
341
376
  """
377
+ await self.pid_event.wait()
378
+ assert self.pid is not None
342
379
  return self.pid
343
380
 
381
+ def clear_pid(self):
382
+ """Clear the PID of the sandboxed process."""
383
+ self.pid_event.clear()
384
+ self.pid = None
385
+
386
+ def use_pgid(self) -> bool:
387
+ """Whether the sandbox supports process groups."""
388
+ return False
389
+
344
390
  @abc.abstractmethod
345
391
  def get_detailed_logs(self) -> str:
346
392
  """Return the detailed logs of the sandbox.
@@ -706,6 +752,15 @@ class SandboxBase(abc.ABC):
706
752
  """
707
753
  pass
708
754
 
755
+ def reset(self):
756
+ """Reset the sandbox.
757
+
758
+ To be called at the beginning of the execution.
759
+
760
+ """
761
+ self.cleanup(delete=True)
762
+ self.initialize()
763
+
709
764
  @abc.abstractmethod
710
765
  def cleanup(self, delete: bool = False):
711
766
  """Cleanup the sandbox.
@@ -599,6 +599,7 @@ class IsolateSandbox(SandboxBase):
599
599
  return (bool|Popen): return True if the sandbox didn't report
600
600
  errors (caused by the sandbox itself), False otherwise.
601
601
  """
602
+ self.clear_pid()
602
603
  popen = self._popen(
603
604
  command,
604
605
  stdin=subprocess.PIPE,
@@ -51,23 +51,22 @@ class StupidSandbox(SandboxBase):
51
51
  SandboxBase.__init__(self, file_cacher, name, temp_dir, params)
52
52
 
53
53
  # Make box directory
54
+ self.initialize()
55
+
56
+ def initialize(self):
54
57
  self._path = pathlib.Path(
55
58
  tempfile.mkdtemp(dir=str(self.temp_dir), prefix='rbx-%s-' % (self.name))
56
59
  )
57
- self.initialize()
58
-
59
60
  self.exec_num = -1
60
61
  self.log = None
61
62
  self.returncode = None
63
+ self._path.mkdir(parents=True, exist_ok=True)
62
64
 
63
65
  logger.debug("Sandbox in `%s' created, using stupid box.", self._path)
64
66
 
65
67
  # Box parameters
66
68
  self.chdir = self._path
67
69
 
68
- def initialize(self):
69
- self._path.mkdir(parents=True, exist_ok=True)
70
-
71
70
  def get_timeit_executable(self) -> pathlib.Path:
72
71
  with importlib.resources.as_file(
73
72
  importlib.resources.files('rbx')
@@ -94,6 +93,8 @@ class StupidSandbox(SandboxBase):
94
93
  args.append(f'-f{self.params.fsize}')
95
94
  if self.chdir:
96
95
  args.append(f'-c{self.chdir}')
96
+ if self.use_pgid() and self.params.pgid is not None:
97
+ args.append(f'-g{self.params.pgid}')
97
98
 
98
99
  file_args = []
99
100
  if self.params.stdin_file:
@@ -148,6 +149,9 @@ class StupidSandbox(SandboxBase):
148
149
  def use_soft_timeout(self) -> bool:
149
150
  return True
150
151
 
152
+ def use_pgid(self) -> bool:
153
+ return True
154
+
151
155
  def get_memory_used(self) -> Optional[int]:
152
156
  """Return the memory used by the sandbox.
153
157
 
@@ -305,6 +309,8 @@ class StupidSandbox(SandboxBase):
305
309
  + self.get_timeit_args()
306
310
  + command
307
311
  )
312
+
313
+ self.clear_pid()
308
314
  with subprocess.Popen(
309
315
  real_command,
310
316
  stdin=subprocess.PIPE,
@@ -5,6 +5,7 @@ import resource
5
5
  import signal
6
6
  import stat
7
7
  import sys
8
+ import threading
8
9
  from time import monotonic
9
10
  from typing import Any, Dict, List, Optional, Set, Union
10
11
 
@@ -25,6 +26,7 @@ class Options:
25
26
  file_duplicates: Dict[int, List[str]] = dataclasses.field(default_factory=dict)
26
27
  prefixed: Set[str] = dataclasses.field(default_factory=set)
27
28
  prefix: str = ''
29
+ process_group: Optional[int] = None
28
30
 
29
31
 
30
32
  def exit_with(code: int):
@@ -91,17 +93,16 @@ def create_tee(files, mode, buffer_size=4096, prefix=''):
91
93
  # Close parent's end of the pipe
92
94
  os.close(pipe_write)
93
95
 
94
- bytes = os.read(pipe_read, buffer_size)
95
- while bytes:
96
+ new = True
97
+ while bytes := os.read(pipe_read, 1):
96
98
  for tee in tee_list:
97
- if tee.prefix:
99
+ if tee.prefix and new:
98
100
  tee.file.write(tee.prefix)
99
101
  tee.file.write(bytes)
100
102
  tee.file.flush()
103
+ new = bytes == b'\n'
101
104
  # TODO maybe add in fsync() here if the fileno() method
102
105
  # exists on file
103
-
104
- bytes = os.read(pipe_read, buffer_size)
105
106
  except Exception:
106
107
  pass
107
108
  finally:
@@ -150,6 +151,8 @@ def parse_opts() -> Options:
150
151
  options.fs_limit = int(opt[2:])
151
152
  elif opt.startswith('-P'):
152
153
  options.prefix = opt[2:]
154
+ elif opt.startswith('-g'):
155
+ options.process_group = int(opt[2:])
153
156
  else:
154
157
  raise Exception(f'Invalid option {opt}')
155
158
  num_opts += 1
@@ -270,6 +273,7 @@ def wait_and_finish(
270
273
  entries.append(f'time-wall: {wall_time:.3f}')
271
274
  entries.append(f'mem: {memory_used}')
272
275
  entries.append(f'file: {file_sizes}')
276
+ entries.append(f'pid: {pid}')
273
277
 
274
278
  output_file = pathlib.Path(sys.argv[1])
275
279
  output_file.parent.mkdir(parents=True, exist_ok=True)
@@ -279,6 +283,9 @@ def wait_and_finish(
279
283
  def main():
280
284
  options = parse_opts()
281
285
 
286
+ if options.process_group is not None:
287
+ os.setpgid(0, options.process_group)
288
+
282
289
  start_time = monotonic()
283
290
  sub_pid = os.fork()
284
291
  if sub_pid == 0:
@@ -292,13 +299,21 @@ def main():
292
299
  alarm_msg: List[Optional[str]] = [None]
293
300
  status_holder: Set[str] = set()
294
301
 
295
- def handle_alarm(*args, **kwargs):
302
+ stop_wall_handler = threading.Event()
303
+ stop_alarm_handler = threading.Event()
304
+
305
+ def handle_wall():
306
+ if stop_wall_handler.wait(options.wall_time_limit):
307
+ return
308
+ stop_alarm_handler.set()
296
309
  nonlocal alarm_msg
297
- wall_time = monotonic() - start_time
298
- if options.wall_time_limit is not None and wall_time > options.wall_time_limit:
299
- alarm_msg[0] = 'wall timelimit'
300
- os.kill(sub_pid, 9)
310
+ alarm_msg[0] = 'wall timelimit'
311
+ os.kill(sub_pid, 9)
312
+
313
+ def handle_alarm():
314
+ if stop_alarm_handler.wait(0.3):
301
315
  return
316
+ nonlocal alarm_msg
302
317
  ru = resource.getrusage(resource.RUSAGE_CHILDREN)
303
318
  if options.time_limit is not None:
304
319
  cpu_time = get_cpu_time(ru)
@@ -313,20 +328,26 @@ def main():
313
328
  os.kill(sub_pid, 9)
314
329
  return
315
330
 
316
- signal.setitimer(signal.ITIMER_REAL, 0.3)
331
+ stop_alarm_handler.clear()
332
+ handle_alarm()
333
+
334
+ alarm_handler = threading.Thread(target=handle_alarm, daemon=True)
335
+ wall_handler = threading.Thread(target=handle_wall, daemon=True)
336
+ alarm_handler.start()
337
+ wall_handler.start()
317
338
 
318
339
  def handle_sub_term(*args, **kwargs):
319
340
  nonlocal status_holder
320
341
  status_holder.add('TE')
321
342
  os.kill(sub_pid, 9)
322
343
 
323
- signal.setitimer(signal.ITIMER_REAL, 0.3)
324
- signal.signal(signal.SIGALRM, handle_alarm)
325
344
  signal.signal(signal.SIGTERM, handle_sub_term)
326
345
 
327
346
  wait_and_finish(sub_pid, options, start_time, status_holder, alarm_msg=alarm_msg)
328
- # Cancel alarm before exiting to avoid surprises.
329
- signal.setitimer(signal.ITIMER_REAL, 0)
347
+
348
+ # Process finished, stop the handlers.
349
+ stop_wall_handler.set()
350
+ stop_alarm_handler.set()
330
351
 
331
352
  # Exit gracefully.
332
353
  sys.exit(0)