bounded_subprocess 1.5.0__tar.gz

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.

Potentially problematic release.


This version of bounded_subprocess might be problematic. Click here for more details.

Files changed (29) hide show
  1. bounded_subprocess-1.5.0/.gitignore +5 -0
  2. bounded_subprocess-1.5.0/LICENSE.txt +9 -0
  3. bounded_subprocess-1.5.0/Makefile +10 -0
  4. bounded_subprocess-1.5.0/PKG-INFO +49 -0
  5. bounded_subprocess-1.5.0/README.md +25 -0
  6. bounded_subprocess-1.5.0/cspell.config.yaml +22 -0
  7. bounded_subprocess-1.5.0/pyproject.toml +38 -0
  8. bounded_subprocess-1.5.0/src/bounded_subprocess/__init__.py +30 -0
  9. bounded_subprocess-1.5.0/src/bounded_subprocess/bounded_subprocess_async.py +33 -0
  10. bounded_subprocess-1.5.0/src/bounded_subprocess/interactive.py +131 -0
  11. bounded_subprocess-1.5.0/src/bounded_subprocess/interactive_async.py +131 -0
  12. bounded_subprocess-1.5.0/src/bounded_subprocess/util.py +110 -0
  13. bounded_subprocess-1.5.0/test/__init__.py +3 -0
  14. bounded_subprocess-1.5.0/test/evil_programs/block_on_inputs.py +2 -0
  15. bounded_subprocess-1.5.0/test/evil_programs/close_outputs.py +7 -0
  16. bounded_subprocess-1.5.0/test/evil_programs/dies_shortly_after_launch.py +5 -0
  17. bounded_subprocess-1.5.0/test/evil_programs/dies_while_writing.py +7 -0
  18. bounded_subprocess-1.5.0/test/evil_programs/does_not_read.py +4 -0
  19. bounded_subprocess-1.5.0/test/evil_programs/fork_bomb.py +4 -0
  20. bounded_subprocess-1.5.0/test/evil_programs/fork_once.py +6 -0
  21. bounded_subprocess-1.5.0/test/evil_programs/long_stdout.py +1 -0
  22. bounded_subprocess-1.5.0/test/evil_programs/sleep_forever.py +4 -0
  23. bounded_subprocess-1.5.0/test/evil_programs/unbounded_output.py +4 -0
  24. bounded_subprocess-1.5.0/test/evil_programs/write_forever_but_no_newline.py +2 -0
  25. bounded_subprocess-1.5.0/test/module_test.py +117 -0
  26. bounded_subprocess-1.5.0/test/test_async.py +74 -0
  27. bounded_subprocess-1.5.0/test/test_interactive.py +115 -0
  28. bounded_subprocess-1.5.0/test/test_interactive_async.py +125 -0
  29. bounded_subprocess-1.5.0/uv.lock +200 -0
@@ -0,0 +1,5 @@
1
+ *.pyc
2
+ __pycache__
3
+ /.venv
4
+ /dist
5
+ /bounded_subprocess.egg-info
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022--2024 Northeastern University
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,10 @@
1
+ .PHONY: test
2
+
3
+ build:
4
+ uv build
5
+
6
+ publish:
7
+ python3 -m twine upload dist/*
8
+
9
+ test:
10
+ uv run python -m pytest
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: bounded_subprocess
3
+ Version: 1.5.0
4
+ Summary: A library to facilitate running subprocesses that may misbehave.
5
+ Project-URL: Homepage, https://github.com/arjunguha/bounded_subprocess
6
+ Project-URL: Bug Tracker, https://github.com/arjunguha/bounded_subprocess
7
+ Author: Arjun Guha, Ming-Ho Yee, Francesca Lucchetti
8
+ License: MIT License
9
+
10
+ Copyright (c) 2022--2024 Northeastern University
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
17
+ License-File: LICENSE.txt
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: typeguard<5.0.0,>=4.4.2
23
+ Description-Content-Type: text/markdown
24
+
25
+ # bounded_subprocess
26
+
27
+ [![PyPI - Version](https://img.shields.io/pypi/v/bounded-subprocess.svg)](https://pypi.org/project/bounded-subprocess)
28
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/bounded-subprocess.svg)](https://pypi.org/project/bounded-subprocess)
29
+
30
+ The `bounded-subprocess` module runs a subprocess with several bounds:
31
+
32
+ 1. The subprocess runs in a Linux session, so the process and all its children
33
+ can be killed;
34
+ 2. The subprocess runs with a given timeout; and
35
+ 3. The parent captures a bounded amount of output from the subprocess and
36
+ discards the rest.
37
+
38
+ Note that the subprocess is not isolated: it can use the network, the filesystem,
39
+ or create new sessions.
40
+
41
+ ## Installation
42
+
43
+ ```console
44
+ python3 -m pip install bounded-subprocess
45
+ ```
46
+
47
+ ## License
48
+
49
+ `bounded-subprocess` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,25 @@
1
+ # bounded_subprocess
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/bounded-subprocess.svg)](https://pypi.org/project/bounded-subprocess)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/bounded-subprocess.svg)](https://pypi.org/project/bounded-subprocess)
5
+
6
+ The `bounded-subprocess` module runs a subprocess with several bounds:
7
+
8
+ 1. The subprocess runs in a Linux session, so the process and all its children
9
+ can be killed;
10
+ 2. The subprocess runs with a given timeout; and
11
+ 3. The parent captures a bounded amount of output from the subprocess and
12
+ discards the rest.
13
+
14
+ Note that the subprocess is not isolated: it can use the network, the filesystem,
15
+ or create new sessions.
16
+
17
+ ## Installation
18
+
19
+ ```console
20
+ python3 -m pip install bounded-subprocess
21
+ ```
22
+
23
+ ## License
24
+
25
+ `bounded-subprocess` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,22 @@
1
+ version: "0.2"
2
+ ignorePaths: []
3
+ dictionaryDefinitions: []
4
+ dictionaries: []
5
+ words:
6
+ - killpg
7
+ - nonblocking
8
+ - pgrep
9
+ - GETFL
10
+ - SETFL
11
+ - NONBLOCK
12
+ - getpgid
13
+ - typeguard
14
+ - tobytes
15
+ - Arjun
16
+ - Guha
17
+ - pytest
18
+ - Lucchetti
19
+ - pythonpath
20
+ - asyncio
21
+ ignoreWords: []
22
+ import: []
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "bounded_subprocess"
3
+ version = "1.5.0"
4
+ authors = [
5
+ { name="Arjun Guha" },
6
+ { name="Ming-Ho Yee" },
7
+ { name="Francesca Lucchetti" }
8
+ ]
9
+ description = "A library to facilitate running subprocesses that may misbehave."
10
+ readme = "README.md"
11
+ license = { file="LICENSE.txt" }
12
+ requires-python = ">=3.9"
13
+ dependencies =[
14
+ "typeguard>=4.4.2,<5.0.0"
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: POSIX :: Linux",
20
+ ]
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.pytest.ini_options]
27
+ pythonpath = ["src"]
28
+
29
+ [project.urls]
30
+ "Homepage" = "https://github.com/arjunguha/bounded_subprocess"
31
+ "Bug Tracker" = "https://github.com/arjunguha/bounded_subprocess"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8.3.5",
36
+ "pytest-asyncio>=1.0.0",
37
+ "pytest-timeout>=2.4.0",
38
+ ]
@@ -0,0 +1,30 @@
1
+ import time
2
+ from typing import List
3
+
4
+ from .util import Result, BoundedSubprocessState, SLEEP_BETWEEN_READS
5
+
6
+
7
+ def run(
8
+ args: List[str],
9
+ timeout_seconds: int = 15,
10
+ max_output_size: int = 2048,
11
+ env=None,
12
+ ) -> Result:
13
+ """
14
+ Runs the given program with arguments. After the timeout elapses, kills the process
15
+ and all other processes in the process group. Captures at most max_output_size bytes
16
+ of stdout and stderr each, and discards any output beyond that.
17
+ """
18
+ state = BoundedSubprocessState(args, env, max_output_size)
19
+
20
+ # We sleep for 0.1 seconds in each iteration.
21
+ max_iterations = timeout_seconds * 10
22
+
23
+ for _ in range(max_iterations):
24
+ keep_reading = state.try_read()
25
+ if keep_reading:
26
+ time.sleep(SLEEP_BETWEEN_READS)
27
+ else:
28
+ break
29
+
30
+ return state.terminate()
@@ -0,0 +1,33 @@
1
+ import asyncio
2
+ from typing import List
3
+ from .util import Result, BoundedSubprocessState, SLEEP_BETWEEN_READS
4
+
5
+
6
+ async def run(
7
+ args: List[str],
8
+ timeout_seconds: int = 15,
9
+ max_output_size: int = 2048,
10
+ env=None,
11
+ ) -> Result:
12
+ """
13
+ Runs the given program with arguments. After the timeout elapses, kills the process
14
+ and all other processes in the process group. Captures at most max_output_size bytes
15
+ of stdout and stderr each, and discards any output beyond that.
16
+ """
17
+ # If you read the code for BoundedSubprocessState, you probably thought we
18
+ # were going to use asyncio.create_subprocess_exec. But, we're not. We're
19
+ # using subprocess.Popen because it supports non-blocking reads. What's
20
+ # async here? It's just the sleep between reads.
21
+ state = BoundedSubprocessState(args, env, max_output_size)
22
+
23
+ # We sleep for 0.1 seconds in each iteration.
24
+ max_iterations = timeout_seconds * 10
25
+
26
+ for _ in range(max_iterations):
27
+ keep_reading = state.try_read()
28
+ if keep_reading:
29
+ await asyncio.sleep(SLEEP_BETWEEN_READS)
30
+ else:
31
+ break
32
+
33
+ return state.terminate()
@@ -0,0 +1,131 @@
1
+ from typeguard import typechecked
2
+ from typing import List, Optional
3
+ import time
4
+ import errno
5
+ import subprocess
6
+ from .util import set_nonblocking
7
+
8
+
9
+ _MAX_BYTES_PER_READ = 1024
10
+ _SLEEP_AFTER_WOUND_BLOCK = 0.5
11
+
12
+
13
+ @typechecked
14
+ class Interactive:
15
+ """
16
+ A class for interacting with a subprocess that is careful to use non-blocking
17
+ I/O so that we can timeout reads and writes.
18
+ """
19
+
20
+ def __init__(self, args: List[str], read_buffer_size: int):
21
+ """
22
+ read_buffer_size is the maximum number of bytes to read from stdout
23
+ and stdout each. If the process writes more than this, the extra bytes
24
+ will be discarded.
25
+ """
26
+ popen = subprocess.Popen(
27
+ args,
28
+ stdin=subprocess.PIPE,
29
+ stdout=subprocess.PIPE,
30
+ # stderr=subprocess.PIPE,
31
+ bufsize=_MAX_BYTES_PER_READ,
32
+ )
33
+ set_nonblocking(popen.stdin)
34
+ set_nonblocking(popen.stdout)
35
+ self._read_buffer_size = read_buffer_size
36
+ self._stdout_saved_bytes = bytearray()
37
+ self._stderr_saved_bytes = bytearray()
38
+ self._popen = popen
39
+
40
+ def close(self, nice_timeout_seconds: int) -> int:
41
+ """
42
+ Close the process and wait for it to exit.
43
+ """
44
+ try:
45
+ self._popen.stdin.close()
46
+ except BlockingIOError:
47
+ # .close() will attempt to flush any buffered writes to stdout
48
+ # before the close returns. This may block, but since the file
49
+ # descriptor is non-blocking, we get a BlockingIOError.
50
+ pass
51
+ self._popen.stdout.close()
52
+ for _ in range(nice_timeout_seconds):
53
+ if self._popen.poll() is not None:
54
+ break
55
+ time.sleep(1)
56
+ self._popen.kill()
57
+ return_code = self._popen.returncode
58
+ return return_code if return_code is not None else -9
59
+
60
+ def write(self, stdin_data: bytes, timeout_seconds: int):
61
+ """
62
+ Write data to the process's stdin.
63
+ """
64
+ if self._popen.poll() is not None:
65
+ return False
66
+
67
+ write_start_index = 0
68
+ start_time = time.time()
69
+ while write_start_index < len(stdin_data):
70
+ try:
71
+ bytes_written = self._popen.stdin.write(stdin_data[write_start_index:])
72
+ self._popen.stdin.flush()
73
+ except BlockingIOError as exn:
74
+ if exn.errno != errno.EAGAIN:
75
+ return False
76
+ bytes_written = exn.characters_written
77
+ time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
78
+ except BrokenPipeError:
79
+ # The child has closed stdin. It is likely dead.
80
+ return False
81
+ write_start_index += bytes_written
82
+ if time.time() - start_time > timeout_seconds:
83
+ return False
84
+ return True
85
+
86
+ def _read_line_from_saved_bytes(self, newline_search_index: int) -> Optional[bytes]:
87
+ """
88
+ Try to read a line of output from the buffer of stdout that this object
89
+ manages itself. The newline_search_index to indicate where to start
90
+ looking for b"\n". Typically will be 0, but there are cases where we
91
+ are certain that the the newline is not in a prefix.
92
+ """
93
+ newline_index = self._stdout_saved_bytes.find(b"\n", newline_search_index)
94
+ if newline_index == -1:
95
+ return None
96
+ # memoryview helps avoid a pointless copy
97
+ line = memoryview(self._stdout_saved_bytes)[:newline_index].tobytes()
98
+ del self._stdout_saved_bytes[: newline_index + 1]
99
+ return line
100
+
101
+ def read_line(self, timeout_seconds: int) -> Optional[bytes]:
102
+ """
103
+ Read a line from the process's stdout.
104
+ """
105
+ from_saved_bytes = self._read_line_from_saved_bytes(0)
106
+ if from_saved_bytes is not None:
107
+ return from_saved_bytes
108
+ if self._popen.poll() is not None:
109
+ return None
110
+ deadline = time.time() + timeout_seconds
111
+ while time.time() < deadline:
112
+ new_bytes = self._popen.stdout.read(_MAX_BYTES_PER_READ)
113
+ # If the read would block, we get None and not zero bytes on MacOS.
114
+ if new_bytes is None:
115
+ time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
116
+ continue
117
+ # If we read 0 bytes, the child has closed stdout and is likely dead.
118
+ if len(new_bytes) == 0:
119
+ return None
120
+ prev_saved_bytes_len = len(self._stdout_saved_bytes)
121
+ self._stdout_saved_bytes.extend(new_bytes)
122
+ from_saved_bytes = self._read_line_from_saved_bytes(prev_saved_bytes_len)
123
+ if from_saved_bytes is not None:
124
+ return from_saved_bytes
125
+ if len(self._stdout_saved_bytes) > self._read_buffer_size:
126
+ del self._stdout_saved_bytes[
127
+ : len(self._stdout_saved_bytes) - self._read_buffer_size
128
+ ]
129
+ time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
130
+
131
+ return None
@@ -0,0 +1,131 @@
1
+ from typeguard import typechecked
2
+ from typing import List, Optional
3
+ import time
4
+ import errno
5
+ import subprocess
6
+ from .util import set_nonblocking
7
+ import asyncio
8
+
9
+ _MAX_BYTES_PER_READ = 1024
10
+ _SLEEP_AFTER_WOUND_BLOCK = 0.5
11
+
12
+
13
+ @typechecked
14
+ class Interactive:
15
+ """
16
+ A class for interacting with a subprocess that is careful to use non-blocking
17
+ I/O so that we can timeout reads and writes.
18
+ """
19
+
20
+ def __init__(self, args: List[str], read_buffer_size: int):
21
+ """
22
+ read_buffer_size is the maximum number of bytes to read from stdout
23
+ and stdout each. If the process writes more than this, the extra bytes
24
+ will be discarded.
25
+ """
26
+ popen = subprocess.Popen(
27
+ args,
28
+ stdin=subprocess.PIPE,
29
+ stdout=subprocess.PIPE,
30
+ # stderr=subprocess.PIPE,
31
+ bufsize=_MAX_BYTES_PER_READ,
32
+ )
33
+ set_nonblocking(popen.stdin)
34
+ set_nonblocking(popen.stdout)
35
+ self._read_buffer_size = read_buffer_size
36
+ self._stdout_saved_bytes = bytearray()
37
+ self._stderr_saved_bytes = bytearray()
38
+ self._popen = popen
39
+
40
+ async def close(self, nice_timeout_seconds: int) -> int:
41
+ """
42
+ Close the process and wait for it to exit.
43
+ """
44
+ try:
45
+ self._popen.stdin.close()
46
+ except BlockingIOError:
47
+ # .close() will attempt to flush any buffered writes to stdout
48
+ # before the close returns. This may block, but since the file
49
+ # descriptor is non-blocking, we get a BlockingIOError.
50
+ pass
51
+ self._popen.stdout.close()
52
+ for _ in range(nice_timeout_seconds):
53
+ if self._popen.poll() is not None:
54
+ break
55
+ await asyncio.sleep(1)
56
+ self._popen.kill()
57
+ return_code = self._popen.returncode
58
+ return return_code if return_code is not None else -9
59
+
60
+ async def write(self, stdin_data: bytes, timeout_seconds: int):
61
+ """
62
+ Write data to the process's stdin.
63
+ """
64
+ if self._popen.poll() is not None:
65
+ return False
66
+
67
+ write_start_index = 0
68
+ start_time = time.time()
69
+ while write_start_index < len(stdin_data):
70
+ try:
71
+ bytes_written = self._popen.stdin.write(stdin_data[write_start_index:])
72
+ self._popen.stdin.flush()
73
+ except BlockingIOError as exn:
74
+ if exn.errno != errno.EAGAIN:
75
+ return False
76
+ bytes_written = exn.characters_written
77
+ await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
78
+ except BrokenPipeError:
79
+ # The child has closed stdin. It is likely dead.
80
+ return False
81
+ write_start_index += bytes_written
82
+ if time.time() - start_time > timeout_seconds:
83
+ return False
84
+ return True
85
+
86
+ def _read_line_from_saved_bytes(self, newline_search_index: int) -> Optional[bytes]:
87
+ """
88
+ Try to read a line of output from the buffer of stdout that this object
89
+ manages itself. The newline_search_index to indicate where to start
90
+ looking for b"\n". Typically will be 0, but there are cases where we
91
+ are certain that the the newline is not in a prefix.
92
+ """
93
+ newline_index = self._stdout_saved_bytes.find(b"\n", newline_search_index)
94
+ if newline_index == -1:
95
+ return None
96
+ # memoryview helps avoid a pointless copy
97
+ line = memoryview(self._stdout_saved_bytes)[:newline_index].tobytes()
98
+ del self._stdout_saved_bytes[: newline_index + 1]
99
+ return line
100
+
101
+ async def read_line(self, timeout_seconds: int) -> Optional[bytes]:
102
+ """
103
+ Read a line from the process's stdout.
104
+ """
105
+ from_saved_bytes = self._read_line_from_saved_bytes(0)
106
+ if from_saved_bytes is not None:
107
+ return from_saved_bytes
108
+ if self._popen.poll() is not None:
109
+ return None
110
+ deadline = time.time() + timeout_seconds
111
+ while time.time() < deadline:
112
+ new_bytes = self._popen.stdout.read(_MAX_BYTES_PER_READ)
113
+ # If the read would block, we get None and not zero bytes on MacOS.
114
+ if new_bytes is None:
115
+ await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
116
+ continue
117
+ # If we read 0 bytes, the child has closed stdout and is likely dead.
118
+ if len(new_bytes) == 0:
119
+ return None
120
+ prev_saved_bytes_len = len(self._stdout_saved_bytes)
121
+ self._stdout_saved_bytes.extend(new_bytes)
122
+ from_saved_bytes = self._read_line_from_saved_bytes(prev_saved_bytes_len)
123
+ if from_saved_bytes is not None:
124
+ return from_saved_bytes
125
+ if len(self._stdout_saved_bytes) > self._read_buffer_size:
126
+ del self._stdout_saved_bytes[
127
+ : len(self._stdout_saved_bytes) - self._read_buffer_size
128
+ ]
129
+ await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
130
+
131
+ return None
@@ -0,0 +1,110 @@
1
+ import subprocess
2
+ import os
3
+ import fcntl
4
+ import signal
5
+
6
+ MAX_BYTES_PER_READ = 1024
7
+ SLEEP_BETWEEN_READS = 0.1
8
+
9
+
10
+ class Result:
11
+ timeout: int
12
+ exit_code: int
13
+ stdout: str
14
+ stderr: str
15
+
16
+ def __init__(self, timeout, exit_code, stdout, stderr):
17
+ self.timeout = timeout
18
+ self.exit_code = exit_code
19
+ self.stdout = stdout
20
+ self.stderr = stderr
21
+
22
+
23
+ def set_nonblocking(reader):
24
+ fd = reader.fileno()
25
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
26
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
27
+
28
+
29
+ class BoundedSubprocessState:
30
+ """
31
+ This class lets us share code between the synchronous and asynchronous
32
+ implementations.
33
+ """
34
+
35
+ def __init__(self, args, env, max_output_size):
36
+ """
37
+ Start the process in a new session.
38
+ """
39
+ p = subprocess.Popen(
40
+ args,
41
+ env=env,
42
+ stdin=subprocess.DEVNULL,
43
+ stdout=subprocess.PIPE,
44
+ stderr=subprocess.PIPE,
45
+ start_new_session=True,
46
+ bufsize=MAX_BYTES_PER_READ,
47
+ )
48
+ set_nonblocking(p.stdout)
49
+ set_nonblocking(p.stderr)
50
+
51
+ self.process_group_id = os.getpgid(p.pid)
52
+ self.p = p
53
+ self.exit_code = None
54
+ self.stdout_saved_bytes = []
55
+ self.stderr_saved_bytes = []
56
+ self.stdout_bytes_read = 0
57
+ self.stderr_bytes_read = 0
58
+ self.max_output_size = max_output_size
59
+
60
+ def try_read(self) -> bool:
61
+ """
62
+ Reads from the process. Returning False indicates that we should stop
63
+ reading.
64
+ """
65
+ this_stdout_read = self.p.stdout.read(MAX_BYTES_PER_READ)
66
+ this_stderr_read = self.p.stderr.read(MAX_BYTES_PER_READ)
67
+ # this_stdout_read and this_stderr_read may be None if stdout or stderr
68
+ # are closed. Without these checks, test_close_output fails.
69
+ if (
70
+ this_stdout_read is not None
71
+ and self.stdout_bytes_read < self.max_output_size
72
+ ):
73
+ self.stdout_saved_bytes.append(this_stdout_read)
74
+ self.stdout_bytes_read += len(this_stdout_read)
75
+ if (
76
+ this_stderr_read is not None
77
+ and self.stderr_bytes_read < self.max_output_size
78
+ ):
79
+ self.stderr_saved_bytes.append(this_stderr_read)
80
+ self.stderr_bytes_read += len(this_stderr_read)
81
+
82
+ self.exit_code = self.p.poll()
83
+ if self.exit_code is not None:
84
+ # Finish reading output if needed.
85
+ left_to_read = self.max_output_size - self.stdout_bytes_read
86
+ if left_to_read <= 0:
87
+ return False
88
+ this_stdout_read = self.p.stdout.read(left_to_read)
89
+ this_stderr_read = self.p.stderr.read(left_to_read)
90
+ if this_stdout_read is not None:
91
+ self.stdout_saved_bytes.append(this_stdout_read)
92
+ if this_stderr_read is not None:
93
+ self.stderr_saved_bytes.append(this_stderr_read)
94
+ return False
95
+ return True
96
+
97
+ def terminate(self) -> Result:
98
+ try:
99
+ # Kills the process group. Without this line, test_fork_once fails.
100
+ os.killpg(self.process_group_id, signal.SIGKILL)
101
+ except ProcessLookupError:
102
+ pass
103
+
104
+ timeout = self.exit_code is None
105
+ exit_code = self.exit_code if self.exit_code is not None else -1
106
+ stdout = b"".join(self.stdout_saved_bytes).decode("utf-8", errors="ignore")
107
+ stderr = b"".join(self.stderr_saved_bytes).decode("utf-8", errors="ignore")
108
+ return Result(
109
+ timeout=timeout, exit_code=exit_code, stdout=stdout, stderr=stderr
110
+ )
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2022-present Arjun Guha <a.guha@northeastern.edu>
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -0,0 +1,2 @@
1
+ while True:
2
+ input()
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+ print("This is the end")
4
+ sys.stdout.close()
5
+ sys.stderr.close()
6
+ while True:
7
+ pass
@@ -0,0 +1,5 @@
1
+ import time
2
+ import os
3
+
4
+ time.sleep(1)
5
+ os._exit(1)
@@ -0,0 +1,7 @@
1
+ import time
2
+ import os
3
+ deadline = time.time() + 1
4
+ print("Will die before next newline", flush=True)
5
+ while time.time() < deadline:
6
+ print("x" * 1024, end="", flush=True)
7
+ os.exit(1)
@@ -0,0 +1,4 @@
1
+ import time
2
+
3
+ while True:
4
+ time.sleep(1)
@@ -0,0 +1,4 @@
1
+ import os
2
+
3
+ while True:
4
+ os.fork()
@@ -0,0 +1,6 @@
1
+ import os
2
+ import time
3
+
4
+ if os.fork() == 0:
5
+ while True:
6
+ time.sleep(60)
@@ -0,0 +1 @@
1
+ print("A" * 25000)
@@ -0,0 +1,4 @@
1
+ import time
2
+
3
+ while True:
4
+ time.sleep(60)
@@ -0,0 +1,4 @@
1
+ b = True
2
+ while True:
3
+ print(b)
4
+ b = not b
@@ -0,0 +1,2 @@
1
+ while True:
2
+ print("x" * 1024, end="", flush=True)