stream-limits 0.2.0__tar.gz → 0.2.1__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 (35) hide show
  1. {stream_limits-0.2.0 → stream_limits-0.2.1}/CHANGELOG.md +12 -0
  2. {stream_limits-0.2.0 → stream_limits-0.2.1}/CITATION.cff +1 -1
  3. {stream_limits-0.2.0 → stream_limits-0.2.1}/PKG-INFO +1 -1
  4. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/launcher.md +29 -12
  5. {stream_limits-0.2.0 → stream_limits-0.2.1}/pyproject.toml +1 -1
  6. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/__init__.py +6 -5
  7. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/launcher.py +82 -6
  8. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/PKG-INFO +1 -1
  9. {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/test_launcher.py +67 -3
  10. {stream_limits-0.2.0 → stream_limits-0.2.1}/LICENSE.md +0 -0
  11. {stream_limits-0.2.0 → stream_limits-0.2.1}/MANIFEST.in +0 -0
  12. {stream_limits-0.2.0 → stream_limits-0.2.1}/README.md +0 -0
  13. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/_static/.gitkeep +0 -0
  14. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/changelog.md +0 -0
  15. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/conf.py +0 -0
  16. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/index.md +0 -0
  17. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/memory.md +0 -0
  18. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/other-libraries.md +0 -0
  19. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/reference.md +0 -0
  20. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/schedulers.md +0 -0
  21. {stream_limits-0.2.0 → stream_limits-0.2.1}/docs/threads.md +0 -0
  22. {stream_limits-0.2.0 → stream_limits-0.2.1}/setup.cfg +0 -0
  23. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/__main__.py +0 -0
  24. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/_exec_cgroup.py +0 -0
  25. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/enforcement.py +0 -0
  26. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/py.typed +0 -0
  27. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/pytest_plugin.py +0 -0
  28. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits/testing.py +0 -0
  29. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/SOURCES.txt +0 -0
  30. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/dependency_links.txt +0 -0
  31. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/requires.txt +0 -0
  32. {stream_limits-0.2.0 → stream_limits-0.2.1}/stream_limits.egg-info/top_level.txt +0 -0
  33. {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/conftest.py +0 -0
  34. {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/test_limits.py +0 -0
  35. {stream_limits-0.2.0 → stream_limits-0.2.1}/tests/test_override_and_libraries.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - **The watchdog no longer counts the pages of memory-mapped files.** A program
6
+ that memory-maps large scratch files, as hdfe-stream does, has those pages in
7
+ its resident memory, but the kernel can reclaim them, so a watchdog that
8
+ counted them stopped healthy programs: a 20-million-row hdfe-stream fit with
9
+ 1.2 GB of its own memory reached 2.3 GB of RSS. The watchdog now limits
10
+ private memory (Linux, Windows; macOS still uses RSS), reported as
11
+ `peak_private_bytes`. `--memory-measure rss` restores the old behaviour.
12
+ - On Windows, `os_peak_rss_bytes` is now the program's exact peak working set,
13
+ read when it exits, instead of the last value seen while sampling.
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  - **Run a program under a memory limit and see how much memory it used.** The new
@@ -7,7 +7,7 @@ authors:
7
7
  - family-names: Tucker
8
8
  given-names: Lee
9
9
  email: lee.c.tucker@census.gov
10
- version: 0.2.0
10
+ version: 0.2.1
11
11
  date-released: 2026-11-04
12
12
  license: MIT
13
13
  repository-code: "https://github.com/leetucker/stream-limits"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stream-limits
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory
5
5
  Author: Lee Tucker
6
6
  License-Expression: MIT
@@ -49,7 +49,18 @@ The command is a list of arguments, run without a shell. GB are decimal
49
49
  | `monitor` | Linux, macOS, Windows | No limit: measures, with an optional timeout |
50
50
 
51
51
  **The watchdog** adds up the memory of the program and every process it has
52
- started, and stops them all when the total passes the limit. Because it samples,
52
+ started, and stops them all when the total passes the limit. It counts
53
+ *private* memory: memory that is not the pages of a file. A program that
54
+ memory-maps a large file (a scratch array on disk, a mapped Parquet file) has
55
+ the file's pages in its resident memory (RSS), but the kernel can drop them and
56
+ read them back when memory runs short, so they are not counted against the
57
+ limit. hdfe-stream is such a program: on a 20-million-row panel its RSS reached
58
+ 2.3 GB while its private memory stayed at 1.2 GB, the same as on 2.6 million
59
+ rows. On Linux private memory is anonymous plus shared memory; on Windows it is
60
+ private bytes (committed memory, which can be more than RSS). macOS has no cheap
61
+ equivalent, so there the watchdog counts RSS. `--memory-measure rss`
62
+ (`memory_measure="rss"`) counts every resident page on every platform, and the
63
+ result's `memory_measure` says which was used. Because it samples,
53
64
  it can overshoot a little before it acts, and a peak shorter than the interval
54
65
  can slip between samples. In practice it is tight: on Linux, with a 1.5 GB limit,
55
66
  a fast 3 GB NumPy allocation, a 2.5 GB spike lasting 20 ms and a grandchild
@@ -74,7 +85,7 @@ running under the watchdog. macOS and Windows have no hard mode.
74
85
  | `ok` | The program finished with exit code 0 |
75
86
  | `command_failed` | It finished with another exit code |
76
87
  | `timeout` | It ran past `timeout` and was stopped |
77
- | `rss_limit_exceeded` | The watchdog stopped it |
88
+ | `rss_limit_exceeded` | The watchdog stopped it (by the measure in `memory_measure`, despite the name) |
78
89
  | `monitoring_failed` | The watchdog could not read its memory and stopped it |
79
90
  | `kernel_oom` | The kernel killed a process for going over a hard limit |
80
91
 
@@ -83,16 +94,19 @@ process. It is reported even when the process killed was a child the program
83
94
  started and the program itself carried on and exited with 0, so check `status`
84
95
  and not only the exit code.
85
96
 
86
- Two peaks are reported:
97
+ Three peaks are reported:
87
98
 
88
- - `os_peak_rss_bytes`: the largest memory of any single process, as the
89
- operating system records it. On Linux and macOS it is exact, and covers the
90
- program and every process it started and waited for (not one it left running
91
- in the background). On Windows it is read while sampling. Use it when a test
92
- needs a reliable peak.
93
- - `peak_rss_bytes`: the largest sampled total over all the program's processes
94
- at once. Use it for a program with several worker processes. A program that
95
- ends before the first sample reports 0.
99
+ - `peak_private_bytes`: the largest sampled total of private memory over all the
100
+ program's processes (`None` on macOS). This is the one to use for a program
101
+ that memory-maps files, and the one the watchdog limits.
102
+ - `os_peak_rss_bytes`: the largest RSS of any single process, as the operating
103
+ system records it. On Linux and macOS it is exact, and covers the program and
104
+ every process it started and waited for (not one it left running in the
105
+ background). On Windows it is the program's own peak working set, read when
106
+ it exits (not its child processes'). It counts mapped file pages. Use it when a test needs a reliable peak of a program that maps no
107
+ large files, or on macOS.
108
+ - `peak_rss_bytes`: the largest sampled total RSS over all the program's
109
+ processes at once. A program that ends before the first sample reports 0.
96
110
 
97
111
  With a hard limit, `result.kernel` adds the kernel's own figures: the limit, any
98
112
  smaller limit set higher up, its peak (which counts file cache and kernel
@@ -230,7 +244,10 @@ and skip without one. The plugin registers the markers `resource` and
230
244
  Polars, NumPy and numba already holds 0.2-0.4 GB, so a small input under a 1.5 GB
231
245
  limit passes whatever the code does with it. To catch code that holds the data in
232
246
  memory, give the test an input larger in memory than the limit less that
233
- baseline, or compare `os_peak_rss_bytes` at N rows and at several times N.
247
+ baseline, or compare peaks at N rows and at several times N:
248
+ `peak_private_bytes` where it is available, since a program that memory-maps
249
+ its scratch files has an RSS that grows with the data even when its private
250
+ memory does not.
234
251
 
235
252
  Use the launcher only for tests about memory. Ordinary tests are better run in
236
253
  the test process, as before.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stream-limits"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -87,7 +87,7 @@ import warnings
87
87
  from pathlib import Path
88
88
  from typing import Iterator, Mapping
89
89
 
90
- __version__ = "0.2.0"
90
+ __version__ = "0.2.1"
91
91
 
92
92
  __all__ = [
93
93
  "set_limits", "limit_threads", "limit_memory", "apply_scheduler_defaults", "thread_scope",
@@ -791,12 +791,13 @@ def thread_scope(n: int | None = None) -> Iterator[None]:
791
791
 
792
792
 
793
793
  # ----------------------------------------------------- peak memory used
794
- def _windows_memory() -> tuple[int, int] | None:
794
+ def _windows_memory(handle=None) -> tuple[int, int] | None:
795
795
  """This process's ``(working set, peak working set)`` in bytes, from the
796
796
  Windows API (``GetProcessMemoryInfo``), or None: off Windows, or if the call
797
797
  fails. The working set is the physical memory resident in the process, which
798
798
  is what ``VmRSS`` is on Linux, and the peak is a high-water mark the system
799
- keeps."""
799
+ keeps. `handle`: another process's handle instead (the launcher reads a
800
+ child's peak after it exits, while its handle is still open)."""
800
801
  if sys.platform != "win32":
801
802
  return None
802
803
  try:
@@ -821,8 +822,8 @@ def _windows_memory() -> tuple[int, int] | None:
821
822
  kernel32.K32GetProcessMemoryInfo.restype = wintypes.BOOL
822
823
  counters = _Counters()
823
824
  counters.cb = ctypes.sizeof(_Counters)
824
- if not kernel32.K32GetProcessMemoryInfo(kernel32.GetCurrentProcess(),
825
- ctypes.byref(counters), counters.cb):
825
+ target = kernel32.GetCurrentProcess() if handle is None else handle
826
+ if not kernel32.K32GetProcessMemoryInfo(target, ctypes.byref(counters), counters.cb):
826
827
  return None
827
828
  return int(counters.WorkingSetSize), int(counters.PeakWorkingSetSize)
828
829
  except Exception: # noqa: BLE001
@@ -18,7 +18,7 @@ import time
18
18
  from collections.abc import Mapping, Sequence
19
19
  from dataclasses import asdict, dataclass
20
20
 
21
- from . import _THREAD_ENV
21
+ from . import _THREAD_ENV, _windows_memory
22
22
  from .enforcement import MemoryCgroup, UnsupportedEnforcement
23
23
 
24
24
 
@@ -39,13 +39,23 @@ class RunResult:
39
39
  descendants_terminated: bool
40
40
  rss_sampling_complete: bool
41
41
  os_peak_rss_bytes: int | None = None
42
+ peak_private_bytes: int | None = None
43
+ memory_measure: str = "rss"
42
44
 
43
45
  # os_peak_rss_bytes: the largest resident set any single process reached,
44
46
  # as the OS records it. POSIX: the child's ru_maxrss from wait4, which
45
47
  # covers the child and the descendants it waited for (exact, not sampled;
46
48
  # not summed over the tree; background processes it left running are not
47
- # included). Windows: the largest peak working set read while sampling.
48
- # None when unavailable.
49
+ # included). Windows: the child's peak working set, read from its handle
50
+ # after it exits (not its descendants'; the largest sampled value if that
51
+ # read fails). None when unavailable. Like RSS, it counts pages of
52
+ # memory-mapped files.
53
+ #
54
+ # peak_private_bytes: the largest sampled total over the process tree of
55
+ # memory that is not a file's pages (see _private_bytes). None on macOS.
56
+ #
57
+ # memory_measure: what the watchdog compared with the limit, "private" or
58
+ # "rss" (asked for, or private memory is unavailable on this platform).
49
59
 
50
60
  def as_dict(self):
51
61
  return asdict(self)
@@ -58,6 +68,7 @@ def capabilities():
58
68
  "psutil_available": importlib.util.find_spec("psutil") is not None,
59
69
  "monitor": True,
60
70
  "watchdog": True,
71
+ "private_memory": private_memory_available(),
61
72
  "hard_backends": ["cgroup-v1", "cgroup-v2"] if sys.platform == "linux" else [],
62
73
  "hard_limit_accounting": "cgroup-charged memory"
63
74
  if sys.platform == "linux"
@@ -78,6 +89,36 @@ def thread_env(threads, env=None):
78
89
  return out
79
90
 
80
91
 
92
+ def _private_bytes(process, info):
93
+ """Memory of one process that is not a file's pages, which the kernel can
94
+ drop and read back (memory-mapped scratch files, mapped libraries).
95
+ Linux: anonymous plus shared memory (RssAnon + RssShmem; shared memory
96
+ is not reclaimable without swap), else RSS less psutil's shared pages.
97
+ Windows: private bytes (committed memory, so it can exceed RSS). None
98
+ elsewhere (macOS has no cheap equivalent)."""
99
+ if sys.platform.startswith("linux"):
100
+ try:
101
+ found = {}
102
+ with open(f"/proc/{process.pid}/status", encoding="ascii") as fh:
103
+ for line in fh:
104
+ if line.startswith(("RssAnon:", "RssShmem:")):
105
+ key, value = line.split()[:2]
106
+ found[key] = int(value) * 1024
107
+ if "RssAnon:" in found:
108
+ return found["RssAnon:"] + found.get("RssShmem:", 0)
109
+ except (OSError, ValueError):
110
+ pass
111
+ return max(0, info.rss - getattr(info, "shared", 0))
112
+ if hasattr(info, "private"):
113
+ return info.private
114
+ return None
115
+
116
+
117
+ def private_memory_available():
118
+ """Whether this platform reports private memory (Linux, Windows)."""
119
+ return sys.platform.startswith("linux") or os.name == "nt"
120
+
121
+
81
122
  def _positive(value, name):
82
123
  if (
83
124
  isinstance(value, bool)
@@ -101,13 +142,20 @@ def run_limited(
101
142
  max_output_bytes=1_000_000,
102
143
  threads: int | None = None,
103
144
  capture_output: bool = True,
145
+ memory_measure: str = "private",
104
146
  ):
105
147
  """Run a command without a shell and return measurements and termination cause.
106
148
 
107
149
  hard: verified Linux cgroup cap, including descendants; no automatic fallback.
108
- watchdog: sampled process-tree RSS threshold, which can overshoot.
150
+ watchdog: sampled process-tree memory threshold, which can overshoot.
109
151
  monitor: measurements/timeouts only, without a memory ceiling.
110
152
 
153
+ memory_measure: what the watchdog compares with the limit. "private"
154
+ (default) leaves out pages of files, which the kernel can reclaim, so a
155
+ program that memory-maps large scratch files is not stopped for them;
156
+ where private memory is unavailable (macOS) RSS is used, and the result's
157
+ memory_measure says so. "rss" counts every resident page.
158
+
111
159
  GB is decimal. The supervisor stays outside the child's hard cap. env has
112
160
  subprocess replacement semantics; no budget/thread variables are injected
113
161
  unless `threads` is given, which sets them all (`thread_env`). Captured
@@ -125,6 +173,13 @@ def run_limited(
125
173
  )
126
174
  if mode not in ("hard", "watchdog", "monitor"):
127
175
  raise ValueError("mode must be hard, watchdog or monitor")
176
+ if memory_measure not in ("private", "rss"):
177
+ raise ValueError("memory_measure must be private or rss")
178
+ measure = (
179
+ "private"
180
+ if memory_measure == "private" and private_memory_available()
181
+ else "rss"
182
+ )
128
183
  if memory_gb is not None:
129
184
  _positive(memory_gb, "memory_gb")
130
185
  if mode != "monitor" and memory_gb is None:
@@ -158,6 +213,7 @@ def run_limited(
158
213
  group = MemoryCgroup(cgroup_parent, limit) if mode == "hard" else None
159
214
  proc, kernel, tracked = None, None, {}
160
215
  reason, peak, descendants = None, 0, False
216
+ private_peak = 0 if private_memory_available() else None
161
217
  os_peak, stopped = None, False
162
218
  sampling_complete = True
163
219
  started = time.monotonic()
@@ -216,6 +272,12 @@ def run_limited(
216
272
  if proc.poll() is None:
217
273
  proc.kill()
218
274
  proc.wait()
275
+ # The exited process's counters stay readable while Popen holds
276
+ # its handle: the exact peak working set, not the last sample.
277
+ handle = getattr(proc, "_handle", None)
278
+ counters = _windows_memory(int(handle)) if handle is not None else None
279
+ if counters is not None:
280
+ os_peak = max(os_peak or 0, counters[1])
219
281
 
220
282
  def tail(handle):
221
283
  size = handle.seek(0, os.SEEK_END)
@@ -239,7 +301,7 @@ def run_limited(
239
301
  except psutil.NoSuchProcess:
240
302
  process = None
241
303
  while not exited():
242
- rss = 0
304
+ rss, private = 0, 0
243
305
  try:
244
306
  members = (
245
307
  [process, *process.children(recursive=True)]
@@ -250,6 +312,8 @@ def run_limited(
250
312
  try:
251
313
  info = p.memory_info()
252
314
  rss += info.rss
315
+ if private_peak is not None:
316
+ private += _private_bytes(p, info) or 0
253
317
  if hasattr(info, "peak_wset"): # Windows
254
318
  os_peak = max(os_peak or 0, info.peak_wset)
255
319
  if p.pid != proc.pid:
@@ -263,10 +327,13 @@ def run_limited(
263
327
  except psutil.AccessDenied:
264
328
  sampling_complete = False
265
329
  peak = max(peak, rss)
330
+ if private_peak is not None:
331
+ private_peak = max(private_peak, private)
266
332
  if mode == "watchdog" and not sampling_complete:
267
333
  reason = "monitoring_failed"
268
334
  break
269
- if mode == "watchdog" and rss > limit:
335
+ watched = private if measure == "private" else rss
336
+ if mode == "watchdog" and watched > limit:
270
337
  reason = "rss_limit_exceeded"
271
338
  break
272
339
  if timeout is not None and time.monotonic() - started >= timeout:
@@ -316,6 +383,8 @@ def run_limited(
316
383
  descendants,
317
384
  sampling_complete,
318
385
  os_peak_rss_bytes=os_peak,
386
+ peak_private_bytes=private_peak,
387
+ memory_measure=measure,
319
388
  )
320
389
  finally:
321
390
  try:
@@ -343,6 +412,12 @@ def main():
343
412
  help="let the command write to this terminal; the JSON summary goes to stderr",
344
413
  )
345
414
  parser.add_argument("--report", help="also write the JSON summary to this file")
415
+ parser.add_argument(
416
+ "--memory-measure",
417
+ choices=["private", "rss"],
418
+ default="private",
419
+ help="what the watchdog limits: private memory (default; not file pages) or RSS",
420
+ )
346
421
  parser.add_argument("--capabilities", action="store_true")
347
422
  parser.add_argument("command", nargs=argparse.REMAINDER)
348
423
  args = parser.parse_args()
@@ -359,6 +434,7 @@ def main():
359
434
  timeout=args.timeout,
360
435
  threads=args.threads,
361
436
  capture_output=not args.no_capture,
437
+ memory_measure=args.memory_measure,
362
438
  )
363
439
  except (ValueError, OSError, RuntimeError, ImportError) as exc:
364
440
  parser.error(str(exc))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stream-limits
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Cap the threads and memory of numeric Python libraries (Polars, numba, BLAS, pyarrow) at a scheduler's allocation, and measure peak memory
5
5
  Author: Lee Tucker
6
6
  License-Expression: MIT
@@ -65,7 +65,11 @@ def test_watchdog_and_timeout_have_distinct_causes():
65
65
  timeout=10,
66
66
  )
67
67
  assert result.status == "rss_limit_exceeded" and result.returncode != 0
68
- assert result.peak_rss_bytes > 64_000_000
68
+ # what the watchdog limited: on Windows private bytes count the bytearray
69
+ # once committed, before its pages are resident
70
+ watched = (result.peak_private_bytes if result.memory_measure == "private"
71
+ else result.peak_rss_bytes)
72
+ assert watched > 64_000_000
69
73
  result = run_limited(
70
74
  python("import time; time.sleep(5)"), mode="monitor", timeout=0.2
71
75
  )
@@ -247,8 +251,6 @@ def test_os_peak_is_exact_and_covers_short_commands():
247
251
  assert result.status == "ok"
248
252
  assert result.os_peak_rss_bytes is not None
249
253
  assert result.os_peak_rss_bytes >= 200_000_000
250
- if os.name != "posix": # Windows reads its peak while sampling
251
- return
252
254
  # too short for any sample: the sampled peak can be 0, the OS's is not
253
255
  quick = run_limited(python("pass"), mode="monitor", timeout=30)
254
256
  assert quick.os_peak_rss_bytes is not None and quick.os_peak_rss_bytes > 0
@@ -328,3 +330,65 @@ def test_cli_without_capture_passes_output_through(tmp_path):
328
330
  summary = json.loads(result.stderr)
329
331
  assert summary["status"] == "ok" and summary["stdout"] == ""
330
332
  assert json.loads(report.read_text()) == summary
333
+
334
+
335
+ # A program that writes a 600 MB file and memory-maps it, touching every page:
336
+ # its RSS grows by the file's size, its private memory does not.
337
+ MAPPED = """
338
+ import mmap, os, sys
339
+ path = sys.argv[1]
340
+ with open(path, "wb") as fh:
341
+ fh.truncate(600_000_000)
342
+ with open(path, "r+b") as fh:
343
+ m = mmap.mmap(fh.fileno(), 0)
344
+ for i in range(0, len(m), 4096):
345
+ m[i] = 1
346
+ m.flush()
347
+ total = sum(m[i] for i in range(0, len(m), 4096))
348
+ import time; time.sleep(0.3)
349
+ print(total)
350
+ """
351
+
352
+
353
+ @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
354
+ def test_watchdog_ignores_mapped_file_pages_by_default(tmp_path):
355
+ command = [sys.executable, "-c", MAPPED, str(tmp_path / "scratch.bin")]
356
+ result = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60)
357
+ assert result.status == "ok", result.as_dict()
358
+ assert result.memory_measure == "private"
359
+ assert result.peak_private_bytes < 200_000_000
360
+ assert result.peak_rss_bytes > 400_000_000 # the mapped pages are resident
361
+ rss = run_limited(command, mode="watchdog", memory_gb=0.3, timeout=60,
362
+ memory_measure="rss")
363
+ assert rss.status == "rss_limit_exceeded" and rss.memory_measure == "rss"
364
+
365
+
366
+ @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux private memory")
367
+ def test_watchdog_stops_private_memory_over_the_limit():
368
+ code = "import time; x=bytearray(500_000_000); x[::4096]=b'1'*len(x[::4096]); time.sleep(5)"
369
+ result = run_limited(python(code), mode="watchdog", memory_gb=0.3, timeout=30)
370
+ assert result.status == "rss_limit_exceeded" and result.memory_measure == "private"
371
+ assert result.peak_private_bytes > 300_000_000
372
+
373
+
374
+ @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux /proc")
375
+ def test_shared_memory_counts_as_private():
376
+ # shared memory (/dev/shm, multiprocessing) cannot be reclaimed without swap
377
+ code = ("from multiprocessing import shared_memory as s; import time; "
378
+ "m=s.SharedMemory(create=True,size=300_000_000); m.buf[::4096]=b'1'*len(m.buf[::4096]); "
379
+ "time.sleep(0.5); m.close(); m.unlink()")
380
+ result = run_limited(python(code), mode="monitor", timeout=30)
381
+ assert result.status == "ok" and result.peak_private_bytes > 250_000_000
382
+
383
+
384
+ def test_private_measure_is_reported_or_falls_back():
385
+ from stream_limits.launcher import private_memory_available
386
+
387
+ result = run_limited(python("import time; time.sleep(0.3)"), mode="watchdog",
388
+ memory_gb=1, timeout=30)
389
+ if private_memory_available():
390
+ assert result.memory_measure == "private" and result.peak_private_bytes > 0
391
+ else:
392
+ assert result.memory_measure == "rss" and result.peak_private_bytes is None
393
+ with pytest.raises(ValueError):
394
+ run_limited(python("pass"), mode="monitor", memory_measure="uss")
File without changes
File without changes
File without changes
File without changes