bounded_subprocess 2.5.0__tar.gz → 2.7.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.
Files changed (38) hide show
  1. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/PKG-INFO +1 -1
  2. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/pyproject.toml +1 -1
  3. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/src/bounded_subprocess/bounded_subprocess_async.py +178 -2
  4. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/test_async.py +82 -0
  5. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/uv.lock +2 -2
  6. bounded_subprocess-2.5.0/.git +0 -1
  7. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/.github/workflows/docs.yaml +0 -0
  8. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/.github/workflows/test.yml +0 -0
  9. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/.gitignore +0 -0
  10. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/AGENTS.md +0 -0
  11. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/LICENSE.txt +0 -0
  12. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/Makefile +0 -0
  13. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/README.md +0 -0
  14. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/cspell.config.yaml +0 -0
  15. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/docs/index.md +0 -0
  16. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/mkdocs.yaml +0 -0
  17. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/src/bounded_subprocess/__init__.py +0 -0
  18. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/src/bounded_subprocess/bounded_subprocess.py +0 -0
  19. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/src/bounded_subprocess/interactive.py +0 -0
  20. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/src/bounded_subprocess/interactive_async.py +0 -0
  21. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/src/bounded_subprocess/util.py +0 -0
  22. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/__init__.py +0 -0
  23. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/block_on_inputs.py +0 -0
  24. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/close_outputs.py +0 -0
  25. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/dies_shortly_after_launch.py +0 -0
  26. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/dies_while_writing.py +0 -0
  27. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/does_not_read.py +0 -0
  28. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/echo_stdin.py +0 -0
  29. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/fork_bomb.py +0 -0
  30. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/fork_once.py +0 -0
  31. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/long_stdout.py +0 -0
  32. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/read_one_line.py +0 -0
  33. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/sleep_forever.py +0 -0
  34. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/unbounded_output.py +0 -0
  35. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/evil_programs/write_forever_but_no_newline.py +0 -0
  36. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/module_test.py +0 -0
  37. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/test_interactive.py +0 -0
  38. {bounded_subprocess-2.5.0 → bounded_subprocess-2.7.0}/test/test_interactive_async.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bounded_subprocess
3
- Version: 2.5.0
3
+ Version: 2.7.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bounded_subprocess"
3
- version = "2.5.0"
3
+ version = "2.7.0"
4
4
  authors = [
5
5
  { name="Arjun Guha" },
6
6
  { name="Ming-Ho Yee" },
@@ -22,6 +22,133 @@ from .util import (
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
24
 
25
+ def _read_process_group_id(pid: int) -> Optional[int]:
26
+ """
27
+ Read a process's PGID from `/proc/<pid>/stat`.
28
+
29
+ We use PGID to approximate "all related processes" without requiring
30
+ cgroups. Parsing `/proc/<pid>/stat` is Linux-specific and somewhat brittle
31
+ if format assumptions ever change, but it avoids external dependencies and
32
+ works on typical cluster nodes where cgroups may not be configured for us.
33
+ """
34
+ stat_path = f"/proc/{pid}/stat"
35
+ try:
36
+ with open(stat_path, "r", encoding="utf-8") as f:
37
+ stat = f.read().strip()
38
+ except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
39
+ return None
40
+
41
+ right_paren = stat.rfind(")")
42
+ if right_paren == -1:
43
+ return None
44
+ rest = stat[right_paren + 1 :].strip().split()
45
+ # After "comm)", fields are: state, ppid, pgrp, ...
46
+ if len(rest) < 3:
47
+ return None
48
+ try:
49
+ return int(rest[2])
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def _read_proc_status_kb(pid: int, metric_name: str) -> Optional[int]:
55
+ """
56
+ Read a memory metric (in kB) from `/proc/<pid>/status`.
57
+
58
+ Returns None when the process is gone or the metric is unavailable.
59
+ This is intentionally best-effort because `/proc` is racy for short-lived
60
+ processes and the watchdog tolerates partial visibility.
61
+ """
62
+ status_path = f"/proc/{pid}/status"
63
+ try:
64
+ with open(status_path, "r", encoding="utf-8") as f:
65
+ for line in f:
66
+ if line.startswith(f"{metric_name}:"):
67
+ parts = line.split()
68
+ if len(parts) >= 2:
69
+ return int(parts[1])
70
+ return None
71
+ except (FileNotFoundError, ProcessLookupError, PermissionError, ValueError):
72
+ return None
73
+ return None
74
+
75
+
76
+ def _read_peak_rss_kb(pid: int) -> Optional[int]:
77
+ """
78
+ Read per-process peak resident memory in kB.
79
+
80
+ Preferred metric is `VmHWM` (peak RSS); if absent, falls back to `VmRSS`
81
+ (current RSS). The fallback makes behavior degrade gracefully on kernels
82
+ that do not expose `VmHWM` consistently, at the cost of weaker "true peak"
83
+ tracking for those processes.
84
+ """
85
+ # VmHWM is the process's peak RSS in kB. Fall back to VmRSS if unavailable.
86
+ peak = _read_proc_status_kb(pid, "VmHWM")
87
+ if peak is not None:
88
+ return peak
89
+ return _read_proc_status_kb(pid, "VmRSS")
90
+
91
+
92
+ def _sum_process_group_peak_rss_kb(process_group_id: int) -> Optional[int]:
93
+ """
94
+ Sum per-process peak RSS across all processes in a process group.
95
+
96
+ This is a deliberate non-cgroup approximation for aggregate memory. It
97
+ overcounts shared pages (RSS semantics), can miss processes that start/exit
98
+ between scans, and is O(number of /proc entries) per poll. Those tradeoffs
99
+ are accepted here to keep enforcement portable on cluster environments
100
+ where we cannot assume cgroup control.
101
+ """
102
+ total_kb = 0
103
+ found_any = False
104
+ for entry in os.scandir("/proc"):
105
+ if not entry.name.isdigit():
106
+ continue
107
+ pid = int(entry.name)
108
+ pgid = _read_process_group_id(pid)
109
+ if pgid != process_group_id:
110
+ continue
111
+ peak_kb = _read_peak_rss_kb(pid)
112
+ if peak_kb is not None:
113
+ total_kb += peak_kb
114
+ found_any = True
115
+ return total_kb if found_any else None
116
+
117
+
118
+ async def _memory_watchdog(
119
+ p: subprocess.Popen,
120
+ process_group_id: int,
121
+ deadline: float,
122
+ memory_limit_mb: int,
123
+ memory_watchdog_interval_seconds: float,
124
+ ) -> bool:
125
+ """
126
+ Enforce `memory_limit_mb` using periodic process-group peak-RSS checks.
127
+
128
+ The watchdog polls aggregate process-group peak RSS and kills the whole
129
+ process group when the limit is exceeded, returning True in that case.
130
+ Polling is interval-based (default 1s, configurable) and intentionally
131
+ approximate: it favors broad compatibility without cgroups over perfect
132
+ accounting precision.
133
+ """
134
+ limit_kb = memory_limit_mb * 1024
135
+ check_interval = max(0.01, memory_watchdog_interval_seconds)
136
+ while True:
137
+ if p.poll() is not None:
138
+ return False
139
+ aggregate_peak_rss_kb = _sum_process_group_peak_rss_kb(process_group_id)
140
+ if aggregate_peak_rss_kb is not None and aggregate_peak_rss_kb > limit_kb:
141
+ try:
142
+ os.killpg(process_group_id, signal.SIGKILL)
143
+ except ProcessLookupError:
144
+ pass
145
+ return True
146
+ remaining = deadline - time.time()
147
+ if remaining <= 0:
148
+ return False
149
+ await asyncio.sleep(min(check_interval, remaining))
150
+
151
+
25
152
  async def run(
26
153
  args: List[str],
27
154
  timeout_seconds: int = 15,
@@ -29,6 +156,8 @@ async def run(
29
156
  env=None,
30
157
  stdin_data: Optional[str] = None,
31
158
  stdin_write_timeout: Optional[int] = None,
159
+ memory_limit_mb: Optional[int] = None,
160
+ memory_watchdog_interval_seconds: float = 1.0,
32
161
  ) -> Result:
33
162
  """
34
163
  Run a subprocess asynchronously with bounded stdout/stderr capture.
@@ -38,7 +167,10 @@ async def run(
38
167
  truncated to `max_output_size` bytes each. If the timeout elapses,
39
168
  `Result.timeout` is True and `Result.exit_code` is -1. If `stdin_data`
40
169
  cannot be fully written before `stdin_write_timeout`, `Result.exit_code`
41
- is set to -1 even if the process exits normally.
170
+ is set to -1 even if the process exits normally. If `memory_limit_mb` is
171
+ set, a watchdog checks aggregate peak RSS (`VmHWM`, summed across the
172
+ process group) at a fixed interval and kills the process group when the
173
+ limit is exceeded.
42
174
 
43
175
  Example:
44
176
 
@@ -91,6 +223,18 @@ async def run(
91
223
  except (BrokenPipeError, BlockingIOError):
92
224
  pass
93
225
 
226
+ memory_watchdog_task = None
227
+ if memory_limit_mb is not None:
228
+ memory_watchdog_task = asyncio.create_task(
229
+ _memory_watchdog(
230
+ p=p,
231
+ process_group_id=process_group_id,
232
+ deadline=deadline,
233
+ memory_limit_mb=memory_limit_mb,
234
+ memory_watchdog_interval_seconds=memory_watchdog_interval_seconds,
235
+ )
236
+ )
237
+
94
238
  bufs = await read_to_eof_async(
95
239
  [p.stdout, p.stderr],
96
240
  timeout_seconds=timeout_seconds,
@@ -110,13 +254,28 @@ async def run(
110
254
  break
111
255
  await asyncio.sleep(min(0.05, remaining))
112
256
 
257
+ memory_limit_exceeded = False
258
+ if memory_watchdog_task is not None:
259
+ if memory_watchdog_task.done():
260
+ memory_limit_exceeded = memory_watchdog_task.result()
261
+ else:
262
+ memory_watchdog_task.cancel()
263
+ try:
264
+ await memory_watchdog_task
265
+ except asyncio.CancelledError:
266
+ pass
267
+
113
268
  try:
114
269
  os.killpg(process_group_id, signal.SIGKILL)
115
270
  except ProcessLookupError:
116
271
  pass
117
272
 
118
273
  exit_code = (
119
- -1 if is_timeout or (stdin_data is not None and not write_ok) else exit_code
274
+ -1
275
+ if is_timeout
276
+ or (stdin_data is not None and not write_ok)
277
+ or memory_limit_exceeded
278
+ else exit_code
120
279
  )
121
280
 
122
281
  return Result(
@@ -165,6 +324,8 @@ async def podman_run(
165
324
  stdin_write_timeout: Optional[int] = None,
166
325
  volumes: List[str] = [],
167
326
  cwd: Optional[str] = None,
327
+ memory_limit_mb: Optional[int] = None,
328
+ entrypoint: Optional[str] = None,
168
329
  ) -> Result:
169
330
  """
170
331
  Run a subprocess in a podman container asynchronously with bounded stdout/stderr capture.
@@ -184,6 +345,12 @@ async def podman_run(
184
345
  stdin_write_timeout: Optional timeout for writing stdin data.
185
346
  volumes: Optional list of volume mount specifications (e.g., ["/host/path:/container/path"]).
186
347
  cwd: Optional working directory path inside the container.
348
+ memory_limit_mb: Optional memory limit in megabytes for the container.
349
+ entrypoint: Optional override for the container image's ENTRYPOINT.
350
+ Passed through to podman's `--entrypoint` flag. Pass `""` to clear
351
+ the image's ENTRYPOINT entirely (so `args` is interpreted as the
352
+ full command). When `None`, the flag is omitted and the image's
353
+ ENTRYPOINT is used unchanged.
187
354
 
188
355
  Example:
189
356
 
@@ -228,10 +395,19 @@ async def podman_run(
228
395
  for volume in volumes:
229
396
  podman_args.extend(["-v", volume])
230
397
 
398
+ # Handle memory limit
399
+ if memory_limit_mb is not None:
400
+ podman_args.extend(["--memory", f"{memory_limit_mb}m", "--memory-swap", f"{memory_limit_mb}m"])
401
+
231
402
  # Handle working directory
232
403
  if cwd is not None:
233
404
  podman_args.extend(["-w", cwd])
234
405
 
406
+ # Handle entrypoint override. Note: `entrypoint=""` is meaningful — it
407
+ # clears the image's ENTRYPOINT — so we test against None, not falsiness.
408
+ if entrypoint is not None:
409
+ podman_args.extend(["--entrypoint", entrypoint])
410
+
235
411
  podman_args.append(image)
236
412
  podman_args.extend(args)
237
413
 
@@ -126,6 +126,59 @@ async def test_read_one_line():
126
126
  await assert_no_running_evil()
127
127
 
128
128
 
129
+ @pytest.mark.asyncio
130
+ async def test_run_memory_limit_ok():
131
+ result = await run(
132
+ ["python3", "-c", "import time; x = bytearray(8 * 1024 * 1024); time.sleep(0.5)"],
133
+ timeout_seconds=5,
134
+ max_output_size=1024,
135
+ memory_limit_mb=256,
136
+ memory_watchdog_interval_seconds=0.05,
137
+ )
138
+ assert result.exit_code == 0
139
+ assert result.timeout is False
140
+ assert len(result.stderr) == 0
141
+
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_run_memory_limit_exceeded():
145
+ result = await run(
146
+ ["python3", "-c", "import time; x = bytearray(128 * 1024 * 1024); time.sleep(5)"],
147
+ timeout_seconds=10,
148
+ max_output_size=1024,
149
+ memory_limit_mb=64,
150
+ memory_watchdog_interval_seconds=0.05,
151
+ )
152
+ assert result.exit_code == -1
153
+ assert result.timeout is False
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_run_memory_limit_exceeded_in_child_process():
158
+ result = await run(
159
+ [
160
+ "python3",
161
+ "-c",
162
+ (
163
+ "import os, time\n"
164
+ "pid = os.fork()\n"
165
+ "_ = bytearray(4 * 1024 * 1024)\n"
166
+ "if pid == 0:\n"
167
+ " _ = bytearray(128 * 1024 * 1024)\n"
168
+ " time.sleep(5)\n"
169
+ "else:\n"
170
+ " time.sleep(5)\n"
171
+ ),
172
+ ],
173
+ timeout_seconds=10,
174
+ max_output_size=1024,
175
+ memory_limit_mb=64,
176
+ memory_watchdog_interval_seconds=0.05,
177
+ )
178
+ assert result.exit_code == -1
179
+ assert result.timeout is False
180
+
181
+
129
182
  @pytest.mark.asyncio
130
183
  async def test_podman_run_stdin():
131
184
  """Test podman_run with stdin_data input."""
@@ -216,3 +269,32 @@ async def test_podman_run_cwd():
216
269
  assert result.exit_code == 0
217
270
  assert result.timeout is False
218
271
  assert result.stdout.strip() == "/tmp"
272
+
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_podman_run_memory_limit_ok():
276
+ """Test podman_run with memory limit that is sufficient."""
277
+ result = await podman_run(
278
+ ["echo", "hello"],
279
+ image="alpine:latest",
280
+ timeout_seconds=10,
281
+ max_output_size=1024,
282
+ memory_limit_mb=64,
283
+ )
284
+ assert result.exit_code == 0
285
+ assert result.timeout is False
286
+ assert result.stdout.strip() == "hello"
287
+
288
+
289
+ @pytest.mark.asyncio
290
+ async def test_podman_run_memory_limit_oom():
291
+ """Test podman_run where the process exceeds the memory limit."""
292
+ # Allocate ~128MB in a container limited to 32MB
293
+ result = await podman_run(
294
+ ["python3", "-c", "x = bytearray(128 * 1024 * 1024)"],
295
+ image="python:3.12-alpine",
296
+ timeout_seconds=30,
297
+ max_output_size=4096,
298
+ memory_limit_mb=32,
299
+ )
300
+ assert result.exit_code != 0
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.9"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.10'",
@@ -31,7 +31,7 @@ wheels = [
31
31
 
32
32
  [[package]]
33
33
  name = "bounded-subprocess"
34
- version = "2.5.0"
34
+ version = "2.7.0"
35
35
  source = { editable = "." }
36
36
  dependencies = [
37
37
  { name = "typeguard" },
@@ -1 +0,0 @@
1
- gitdir: /media/external0/arjun-nosudo/repos/arjunguha/bounded_subprocess/bare/worktrees/main