bounded_subprocess 2.0.0__tar.gz → 2.2.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.
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/PKG-INFO +1 -1
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/pyproject.toml +1 -1
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/src/bounded_subprocess/bounded_subprocess.py +19 -4
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/src/bounded_subprocess/bounded_subprocess_async.py +19 -3
- bounded_subprocess-2.2.0/src/bounded_subprocess/interactive.py +124 -0
- bounded_subprocess-2.2.0/src/bounded_subprocess/interactive_async.py +58 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/src/bounded_subprocess/util.py +110 -6
- bounded_subprocess-2.2.0/test/evil_programs/echo_stdin.py +4 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/module_test.py +29 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/test_async.py +31 -0
- bounded_subprocess-2.0.0/src/bounded_subprocess/interactive.py +0 -131
- bounded_subprocess-2.0.0/src/bounded_subprocess/interactive_async.py +0 -131
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/.github/workflows/test.yml +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/.gitignore +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/LICENSE.txt +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/Makefile +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/README.md +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/cspell.config.yaml +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/src/bounded_subprocess/__init__.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/__init__.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/block_on_inputs.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/close_outputs.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/dies_shortly_after_launch.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/dies_while_writing.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/does_not_read.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/fork_bomb.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/fork_once.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/long_stdout.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/sleep_forever.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/unbounded_output.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/write_forever_but_no_newline.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/test_interactive.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/test_interactive_async.py +0 -0
- {bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bounded_subprocess
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: A library to facilitate running subprocesses that may misbehave.
|
|
5
5
|
Project-URL: Homepage, https://github.com/arjunguha/bounded_subprocess
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/arjunguha/bounded_subprocess
|
{bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/src/bounded_subprocess/bounded_subprocess.py
RENAMED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from typing import List
|
|
2
|
+
from typing import List, Optional
|
|
3
3
|
|
|
4
|
-
from .util import
|
|
4
|
+
from .util import (
|
|
5
|
+
Result,
|
|
6
|
+
BoundedSubprocessState,
|
|
7
|
+
SLEEP_BETWEEN_READS,
|
|
8
|
+
write_loop_sync,
|
|
9
|
+
_STDIN_WRITE_TIMEOUT,
|
|
10
|
+
)
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
def run(
|
|
@@ -9,13 +15,22 @@ def run(
|
|
|
9
15
|
timeout_seconds: int = 15,
|
|
10
16
|
max_output_size: int = 2048,
|
|
11
17
|
env=None,
|
|
18
|
+
stdin_data: Optional[str] = None,
|
|
12
19
|
) -> Result:
|
|
13
20
|
"""
|
|
14
21
|
Runs the given program with arguments. After the timeout elapses, kills the process
|
|
15
22
|
and all other processes in the process group. Captures at most max_output_size bytes
|
|
16
23
|
of stdout and stderr each, and discards any output beyond that.
|
|
17
24
|
"""
|
|
18
|
-
state = BoundedSubprocessState(args, env, max_output_size)
|
|
25
|
+
state = BoundedSubprocessState(args, env, max_output_size, stdin_data is not None)
|
|
26
|
+
if stdin_data is not None:
|
|
27
|
+
write_loop_sync(
|
|
28
|
+
state.write_chunk,
|
|
29
|
+
stdin_data.encode(),
|
|
30
|
+
_STDIN_WRITE_TIMEOUT,
|
|
31
|
+
sleep_interval=SLEEP_BETWEEN_READS,
|
|
32
|
+
)
|
|
33
|
+
state.close_stdin()
|
|
19
34
|
|
|
20
35
|
# We sleep for 0.1 seconds in each iteration.
|
|
21
36
|
max_iterations = timeout_seconds * 10
|
|
@@ -27,4 +42,4 @@ def run(
|
|
|
27
42
|
else:
|
|
28
43
|
break
|
|
29
44
|
|
|
30
|
-
return state.terminate()
|
|
45
|
+
return state.terminate()
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
from typing import List
|
|
3
|
-
from .util import
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from .util import (
|
|
4
|
+
Result,
|
|
5
|
+
BoundedSubprocessState,
|
|
6
|
+
SLEEP_BETWEEN_READS,
|
|
7
|
+
write_loop_async,
|
|
8
|
+
_STDIN_WRITE_TIMEOUT,
|
|
9
|
+
)
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
async def run(
|
|
@@ -8,6 +14,7 @@ async def run(
|
|
|
8
14
|
timeout_seconds: int = 15,
|
|
9
15
|
max_output_size: int = 2048,
|
|
10
16
|
env=None,
|
|
17
|
+
stdin_data: Optional[str] = None,
|
|
11
18
|
) -> Result:
|
|
12
19
|
"""
|
|
13
20
|
Runs the given program with arguments. After the timeout elapses, kills the process
|
|
@@ -18,7 +25,15 @@ async def run(
|
|
|
18
25
|
# were going to use asyncio.create_subprocess_exec. But, we're not. We're
|
|
19
26
|
# using subprocess.Popen because it supports non-blocking reads. What's
|
|
20
27
|
# async here? It's just the sleep between reads.
|
|
21
|
-
state = BoundedSubprocessState(args, env, max_output_size)
|
|
28
|
+
state = BoundedSubprocessState(args, env, max_output_size, stdin_data is not None)
|
|
29
|
+
if stdin_data is not None:
|
|
30
|
+
await write_loop_async(
|
|
31
|
+
state.write_chunk,
|
|
32
|
+
stdin_data.encode(),
|
|
33
|
+
_STDIN_WRITE_TIMEOUT,
|
|
34
|
+
sleep_interval=SLEEP_BETWEEN_READS,
|
|
35
|
+
)
|
|
36
|
+
await state.close_stdin_async(_STDIN_WRITE_TIMEOUT)
|
|
22
37
|
|
|
23
38
|
# We sleep for 0.1 seconds in each iteration.
|
|
24
39
|
max_iterations = timeout_seconds * 10
|
|
@@ -31,3 +46,4 @@ async def run(
|
|
|
31
46
|
break
|
|
32
47
|
|
|
33
48
|
return state.terminate()
|
|
49
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
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, MAX_BYTES_PER_READ, write_loop_sync
|
|
7
|
+
|
|
8
|
+
_SLEEP_AFTER_WOUND_BLOCK = 0.5
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _InteractiveState:
|
|
12
|
+
"""Shared implementation for synchronous and asynchronous interaction."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, args: List[str], read_buffer_size: int) -> None:
|
|
15
|
+
popen = subprocess.Popen(
|
|
16
|
+
args,
|
|
17
|
+
stdin=subprocess.PIPE,
|
|
18
|
+
stdout=subprocess.PIPE,
|
|
19
|
+
bufsize=MAX_BYTES_PER_READ,
|
|
20
|
+
)
|
|
21
|
+
set_nonblocking(popen.stdin)
|
|
22
|
+
set_nonblocking(popen.stdout)
|
|
23
|
+
self.popen = popen
|
|
24
|
+
self.read_buffer_size = read_buffer_size
|
|
25
|
+
self.stdout_saved_bytes = bytearray()
|
|
26
|
+
|
|
27
|
+
# --- low level helpers -------------------------------------------------
|
|
28
|
+
def poll(self) -> Optional[int]:
|
|
29
|
+
return self.popen.poll()
|
|
30
|
+
|
|
31
|
+
def close_pipes(self) -> None:
|
|
32
|
+
try:
|
|
33
|
+
self.popen.stdin.close()
|
|
34
|
+
except BlockingIOError:
|
|
35
|
+
pass
|
|
36
|
+
self.popen.stdout.close()
|
|
37
|
+
|
|
38
|
+
def kill(self) -> None:
|
|
39
|
+
self.popen.kill()
|
|
40
|
+
|
|
41
|
+
def return_code(self) -> int:
|
|
42
|
+
rc = self.popen.returncode
|
|
43
|
+
return rc if rc is not None else -9
|
|
44
|
+
|
|
45
|
+
def write_chunk(self, data: memoryview) -> tuple[int, bool]:
|
|
46
|
+
try:
|
|
47
|
+
written = self.popen.stdin.write(data)
|
|
48
|
+
self.popen.stdin.flush()
|
|
49
|
+
return written, True
|
|
50
|
+
except BlockingIOError as exn:
|
|
51
|
+
if exn.errno != errno.EAGAIN:
|
|
52
|
+
return exn.characters_written, False
|
|
53
|
+
return exn.characters_written, True
|
|
54
|
+
except BrokenPipeError:
|
|
55
|
+
return 0, False
|
|
56
|
+
|
|
57
|
+
def read_chunk(self) -> Optional[bytes]:
|
|
58
|
+
return self.popen.stdout.read(MAX_BYTES_PER_READ)
|
|
59
|
+
|
|
60
|
+
def pop_line(self, start_idx: int) -> Optional[bytes]:
|
|
61
|
+
newline_index = self.stdout_saved_bytes.find(b"\n", start_idx)
|
|
62
|
+
if newline_index == -1:
|
|
63
|
+
return None
|
|
64
|
+
line = memoryview(self.stdout_saved_bytes)[:newline_index].tobytes()
|
|
65
|
+
del self.stdout_saved_bytes[: newline_index + 1]
|
|
66
|
+
return line
|
|
67
|
+
|
|
68
|
+
def append_stdout(self, data: bytes) -> None:
|
|
69
|
+
self.stdout_saved_bytes.extend(data)
|
|
70
|
+
|
|
71
|
+
def trim_stdout(self) -> None:
|
|
72
|
+
if len(self.stdout_saved_bytes) > self.read_buffer_size:
|
|
73
|
+
del self.stdout_saved_bytes[: len(self.stdout_saved_bytes) - self.read_buffer_size]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@typechecked
|
|
77
|
+
class Interactive:
|
|
78
|
+
"""Interact with a subprocess using non-blocking I/O."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, args: List[str], read_buffer_size: int) -> None:
|
|
81
|
+
self._state = _InteractiveState(args, read_buffer_size)
|
|
82
|
+
|
|
83
|
+
def close(self, nice_timeout_seconds: int) -> int:
|
|
84
|
+
self._state.close_pipes()
|
|
85
|
+
for _ in range(nice_timeout_seconds):
|
|
86
|
+
if self._state.poll() is not None:
|
|
87
|
+
break
|
|
88
|
+
time.sleep(1)
|
|
89
|
+
self._state.kill()
|
|
90
|
+
return self._state.return_code()
|
|
91
|
+
|
|
92
|
+
def write(self, stdin_data: bytes, timeout_seconds: int) -> bool:
|
|
93
|
+
if self._state.poll() is not None:
|
|
94
|
+
return False
|
|
95
|
+
return write_loop_sync(
|
|
96
|
+
self._state.write_chunk,
|
|
97
|
+
stdin_data,
|
|
98
|
+
timeout_seconds,
|
|
99
|
+
sleep_interval=_SLEEP_AFTER_WOUND_BLOCK,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def read_line(self, timeout_seconds: int) -> Optional[bytes]:
|
|
103
|
+
line = self._state.pop_line(0)
|
|
104
|
+
if line is not None:
|
|
105
|
+
return line
|
|
106
|
+
if self._state.poll() is not None:
|
|
107
|
+
return None
|
|
108
|
+
deadline = time.time() + timeout_seconds
|
|
109
|
+
while time.time() < deadline:
|
|
110
|
+
new_bytes = self._state.read_chunk()
|
|
111
|
+
if new_bytes is None:
|
|
112
|
+
time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
113
|
+
continue
|
|
114
|
+
if len(new_bytes) == 0:
|
|
115
|
+
return None
|
|
116
|
+
prev_len = len(self._state.stdout_saved_bytes)
|
|
117
|
+
self._state.append_stdout(new_bytes)
|
|
118
|
+
line = self._state.pop_line(prev_len)
|
|
119
|
+
if line is not None:
|
|
120
|
+
return line
|
|
121
|
+
self._state.trim_stdout()
|
|
122
|
+
time.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
123
|
+
return None
|
|
124
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typeguard import typechecked
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .interactive import _InteractiveState, _SLEEP_AFTER_WOUND_BLOCK
|
|
7
|
+
from .util import write_loop_async
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@typechecked
|
|
11
|
+
class Interactive:
|
|
12
|
+
"""Asynchronous interface for interacting with a subprocess."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, args: List[str], read_buffer_size: int) -> None:
|
|
15
|
+
self._state = _InteractiveState(args, read_buffer_size)
|
|
16
|
+
|
|
17
|
+
async def close(self, nice_timeout_seconds: int) -> int:
|
|
18
|
+
self._state.close_pipes()
|
|
19
|
+
for _ in range(nice_timeout_seconds):
|
|
20
|
+
if self._state.poll() is not None:
|
|
21
|
+
break
|
|
22
|
+
await asyncio.sleep(1)
|
|
23
|
+
self._state.kill()
|
|
24
|
+
return self._state.return_code()
|
|
25
|
+
|
|
26
|
+
async def write(self, stdin_data: bytes, timeout_seconds: int) -> bool:
|
|
27
|
+
if self._state.poll() is not None:
|
|
28
|
+
return False
|
|
29
|
+
return await write_loop_async(
|
|
30
|
+
self._state.write_chunk,
|
|
31
|
+
stdin_data,
|
|
32
|
+
timeout_seconds,
|
|
33
|
+
sleep_interval=_SLEEP_AFTER_WOUND_BLOCK,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async def read_line(self, timeout_seconds: int) -> Optional[bytes]:
|
|
37
|
+
line = self._state.pop_line(0)
|
|
38
|
+
if line is not None:
|
|
39
|
+
return line
|
|
40
|
+
if self._state.poll() is not None:
|
|
41
|
+
return None
|
|
42
|
+
deadline = time.time() + timeout_seconds
|
|
43
|
+
while time.time() < deadline:
|
|
44
|
+
new_bytes = self._state.read_chunk()
|
|
45
|
+
if new_bytes is None:
|
|
46
|
+
await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
47
|
+
continue
|
|
48
|
+
if len(new_bytes) == 0:
|
|
49
|
+
return None
|
|
50
|
+
prev_len = len(self._state.stdout_saved_bytes)
|
|
51
|
+
self._state.append_stdout(new_bytes)
|
|
52
|
+
line = self._state.pop_line(prev_len)
|
|
53
|
+
if line is not None:
|
|
54
|
+
return line
|
|
55
|
+
self._state.trim_stdout()
|
|
56
|
+
await asyncio.sleep(_SLEEP_AFTER_WOUND_BLOCK)
|
|
57
|
+
return None
|
|
58
|
+
|
|
@@ -2,9 +2,14 @@ import subprocess
|
|
|
2
2
|
import os
|
|
3
3
|
import fcntl
|
|
4
4
|
import signal
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
import errno
|
|
7
|
+
import time
|
|
8
|
+
import asyncio
|
|
5
9
|
|
|
6
10
|
MAX_BYTES_PER_READ = 1024
|
|
7
11
|
SLEEP_BETWEEN_READS = 0.1
|
|
12
|
+
_STDIN_WRITE_TIMEOUT = 3
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
class Result:
|
|
@@ -26,20 +31,79 @@ def set_nonblocking(reader):
|
|
|
26
31
|
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
|
27
32
|
|
|
28
33
|
|
|
34
|
+
def popen_write_chunk(p: subprocess.Popen, data: memoryview) -> tuple[int, bool]:
|
|
35
|
+
"""Try writing a chunk of bytes to a non-blocking Popen stdin."""
|
|
36
|
+
try:
|
|
37
|
+
written = p.stdin.write(data)
|
|
38
|
+
p.stdin.flush()
|
|
39
|
+
if written is None:
|
|
40
|
+
written = 0
|
|
41
|
+
return written, True
|
|
42
|
+
except BlockingIOError as exn:
|
|
43
|
+
if exn.errno != errno.EAGAIN:
|
|
44
|
+
return exn.characters_written, False
|
|
45
|
+
return exn.characters_written, True
|
|
46
|
+
except BrokenPipeError:
|
|
47
|
+
return 0, False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_loop_sync(
|
|
51
|
+
write_chunk: Callable[[memoryview], tuple[int, bool]],
|
|
52
|
+
data: bytes,
|
|
53
|
+
timeout_seconds: float,
|
|
54
|
+
*,
|
|
55
|
+
sleep_interval: float,
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Repeatedly write data using write_chunk until complete or timeout."""
|
|
58
|
+
mv = memoryview(data)
|
|
59
|
+
start = 0
|
|
60
|
+
start_time = time.time()
|
|
61
|
+
while start < len(mv):
|
|
62
|
+
written, keep_going = write_chunk(mv[start:])
|
|
63
|
+
start += written
|
|
64
|
+
if not keep_going:
|
|
65
|
+
return False
|
|
66
|
+
if start < len(mv):
|
|
67
|
+
if time.time() - start_time > timeout_seconds:
|
|
68
|
+
return False
|
|
69
|
+
time.sleep(sleep_interval)
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def write_loop_async(
|
|
74
|
+
write_chunk: Callable[[memoryview], tuple[int, bool]],
|
|
75
|
+
data: bytes,
|
|
76
|
+
timeout_seconds: float,
|
|
77
|
+
*,
|
|
78
|
+
sleep_interval: float,
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""Asynchronously write data until complete or timeout."""
|
|
81
|
+
mv = memoryview(data)
|
|
82
|
+
start = 0
|
|
83
|
+
start_time = time.time()
|
|
84
|
+
while start < len(mv):
|
|
85
|
+
written, keep_going = write_chunk(mv[start:])
|
|
86
|
+
start += written
|
|
87
|
+
if not keep_going:
|
|
88
|
+
return False
|
|
89
|
+
if start < len(mv):
|
|
90
|
+
if time.time() - start_time > timeout_seconds:
|
|
91
|
+
return False
|
|
92
|
+
await asyncio.sleep(sleep_interval)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
29
96
|
class BoundedSubprocessState:
|
|
30
|
-
"""
|
|
31
|
-
This class lets us share code between the synchronous and asynchronous
|
|
32
|
-
implementations.
|
|
33
|
-
"""
|
|
97
|
+
"""State shared between synchronous and asynchronous subprocess helpers."""
|
|
34
98
|
|
|
35
|
-
def __init__(self, args, env, max_output_size):
|
|
99
|
+
def __init__(self, args, env, max_output_size, use_stdin_pipe: bool = False):
|
|
36
100
|
"""
|
|
37
101
|
Start the process in a new session.
|
|
38
102
|
"""
|
|
39
103
|
p = subprocess.Popen(
|
|
40
104
|
args,
|
|
41
105
|
env=env,
|
|
42
|
-
stdin=subprocess.DEVNULL,
|
|
106
|
+
stdin=subprocess.PIPE if use_stdin_pipe else subprocess.DEVNULL,
|
|
43
107
|
stdout=subprocess.PIPE,
|
|
44
108
|
stderr=subprocess.PIPE,
|
|
45
109
|
start_new_session=True,
|
|
@@ -47,6 +111,8 @@ class BoundedSubprocessState:
|
|
|
47
111
|
)
|
|
48
112
|
set_nonblocking(p.stdout)
|
|
49
113
|
set_nonblocking(p.stderr)
|
|
114
|
+
if use_stdin_pipe:
|
|
115
|
+
set_nonblocking(p.stdin)
|
|
50
116
|
|
|
51
117
|
self.process_group_id = os.getpgid(p.pid)
|
|
52
118
|
self.p = p
|
|
@@ -57,6 +123,44 @@ class BoundedSubprocessState:
|
|
|
57
123
|
self.stderr_bytes_read = 0
|
|
58
124
|
self.max_output_size = max_output_size
|
|
59
125
|
|
|
126
|
+
def write_chunk(self, data: memoryview) -> tuple[int, bool]:
|
|
127
|
+
if self.p.stdin is None:
|
|
128
|
+
return 0, False
|
|
129
|
+
try:
|
|
130
|
+
written = self.p.stdin.write(data)
|
|
131
|
+
self.p.stdin.flush()
|
|
132
|
+
if written is None:
|
|
133
|
+
written = 0
|
|
134
|
+
return written, True
|
|
135
|
+
except BlockingIOError as exn:
|
|
136
|
+
if exn.errno != errno.EAGAIN:
|
|
137
|
+
return exn.characters_written, False
|
|
138
|
+
return exn.characters_written, True
|
|
139
|
+
except BrokenPipeError:
|
|
140
|
+
return 0, False
|
|
141
|
+
|
|
142
|
+
def close_stdin(self) -> None:
|
|
143
|
+
if self.p.stdin is None:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
self.p.stdin.close()
|
|
147
|
+
except BrokenPipeError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
async def close_stdin_async(self, timeout: int) -> None:
|
|
151
|
+
if self.p.stdin is None:
|
|
152
|
+
return
|
|
153
|
+
for _ in range(timeout):
|
|
154
|
+
try:
|
|
155
|
+
self.p.stdin.close()
|
|
156
|
+
return
|
|
157
|
+
except BlockingIOError:
|
|
158
|
+
await asyncio.sleep(1)
|
|
159
|
+
except BrokenPipeError:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
60
164
|
def try_read(self) -> bool:
|
|
61
165
|
"""
|
|
62
166
|
Reads from the process. Returning False indicates that we should stop
|
|
@@ -116,4 +116,33 @@ def test_long_stdout():
|
|
|
116
116
|
assert result.timeout == False
|
|
117
117
|
# leave some leeway space for encoding
|
|
118
118
|
assert 9500 <= len(result.stdout) <= 10500
|
|
119
|
+
assert_no_running_evil()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_stdin_data_does_not_read():
|
|
123
|
+
data = "hello world\n"
|
|
124
|
+
result = run(
|
|
125
|
+
["python3", ROOT / "does_not_read.py"],
|
|
126
|
+
timeout_seconds=2,
|
|
127
|
+
max_output_size=1024,
|
|
128
|
+
stdin_data=data,
|
|
129
|
+
)
|
|
130
|
+
assert result.exit_code == -1
|
|
131
|
+
assert result.timeout is True
|
|
132
|
+
assert len(result.stdout) == 0
|
|
133
|
+
assert len(result.stderr) == 0
|
|
134
|
+
assert_no_running_evil()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_stdin_data_echo():
|
|
138
|
+
data = "hello world\n"
|
|
139
|
+
result = run(
|
|
140
|
+
["python3", ROOT / "echo_stdin.py"],
|
|
141
|
+
timeout_seconds=2,
|
|
142
|
+
max_output_size=1024,
|
|
143
|
+
stdin_data=data,
|
|
144
|
+
)
|
|
145
|
+
assert result.exit_code == 0
|
|
146
|
+
assert result.timeout is False
|
|
147
|
+
assert result.stdout == data
|
|
119
148
|
assert_no_running_evil()
|
|
@@ -72,3 +72,34 @@ async def test_concurrent_sleep():
|
|
|
72
72
|
assert all(len(r.stderr) == 0 for r in results)
|
|
73
73
|
assert time.time() - start_time < 1.1
|
|
74
74
|
await assert_no_running_evil()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_stdin_data_async_does_not_read():
|
|
79
|
+
data = "hello async\n"
|
|
80
|
+
result = await run(
|
|
81
|
+
["python3", ROOT / "does_not_read.py"],
|
|
82
|
+
timeout_seconds=2,
|
|
83
|
+
max_output_size=1024,
|
|
84
|
+
stdin_data=data,
|
|
85
|
+
)
|
|
86
|
+
assert result.exit_code == -1
|
|
87
|
+
assert result.timeout is True
|
|
88
|
+
assert len(result.stdout) == 0
|
|
89
|
+
assert len(result.stderr) == 0
|
|
90
|
+
await assert_no_running_evil()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_stdin_data_async_echo():
|
|
95
|
+
data = "hello async\n"
|
|
96
|
+
result = await run(
|
|
97
|
+
["python3", ROOT / "echo_stdin.py"],
|
|
98
|
+
timeout_seconds=2,
|
|
99
|
+
max_output_size=1024,
|
|
100
|
+
stdin_data=data,
|
|
101
|
+
)
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
assert result.timeout is False
|
|
104
|
+
assert result.stdout == data
|
|
105
|
+
await assert_no_running_evil()
|
|
@@ -1,131 +0,0 @@
|
|
|
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
|
|
@@ -1,131 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/dies_while_writing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bounded_subprocess-2.0.0 → bounded_subprocess-2.2.0}/test/evil_programs/unbounded_output.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|