Skip to content

Commit 3a0c86a

Browse files
gh-75876: Run bigmem tests in a subprocess
A test which really allocates the memory it asks for (that is, run with -M) now runs in a subprocess, so that the memory it uses and the address space it fragments are released when it ends. A dummy run stays in the process. The parent process watches the memory usage of the subprocess while waiting for it, so the separate watchdog process is no longer needed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c3aefdb commit 3a0c86a

5 files changed

Lines changed: 147 additions & 109 deletions

File tree

Lib/test/_isolated_sample.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import sys
1111
import time
1212
import unittest
13+
from test import support
1314
from test.support import isolation
1415

1516
# DurationSample sleeps this long in the subprocess; a parent-reported duration
@@ -178,3 +179,12 @@ class TimeoutSample(unittest.TestCase):
178179
@isolation.runInSubprocess(timeout=TIMEOUT)
179180
def test_hang(self):
180181
time.sleep(TIMEOUT_HANG)
182+
183+
184+
class BigmemSample(unittest.TestCase):
185+
186+
@support.bigmemtest(size=1024, memuse=1)
187+
def test_where_it_runs(self, size):
188+
# A real run is isolated by bigmemtest() itself, a dummy run is not.
189+
self.assertEqual(isolation.runningInSubprocess,
190+
bool(support.real_max_memuse))

Lib/test/memory_watchdog.py

Lines changed: 0 additions & 40 deletions
This file was deleted.

Lib/test/support/__init__.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,26 +1257,17 @@ def set_memlimit(limit: str) -> None:
12571257
max_memuse = memlimit
12581258

12591259

1260-
class _MemoryWatchdog:
1261-
"""An object which periodically watches the process' memory consumption
1262-
and prints it out.
1263-
"""
1264-
1265-
def __init__(self):
1266-
self.started = False
1260+
def _memory_watchdog(pid):
1261+
"""Return a function printing the memory usage of process *pid*."""
1262+
# Imported here: test.support does not depend on test.libregrtest.
1263+
from test.libregrtest.utils import get_process_memory_usage
12671264

1268-
def start(self):
1269-
import subprocess
1270-
watchdog_script = findfile("memory_watchdog.py")
1271-
cmd = [sys.executable, watchdog_script, str(os.getpid())]
1272-
self.mem_watchdog = subprocess.Popen(cmd)
1273-
self.started = True
1274-
1275-
def stop(self):
1276-
if not self.started:
1277-
return
1278-
self.mem_watchdog.terminate()
1279-
self.mem_watchdog.wait()
1265+
def watch():
1266+
mem = get_process_memory_usage(pid)
1267+
if mem is not None:
1268+
print(f" ... process data size: {mem / (1024 ** 3):.1f} GiB",
1269+
flush=True)
1270+
return watch
12801271

12811272

12821273
def bigmemtest(size, memuse, dry_run=True):
@@ -1291,8 +1282,14 @@ def bigmemtest(size, memuse, dry_run=True):
12911282
extra argument. If 'dry_run' is true, the value passed to the test method
12921283
may be less than the requested value. If 'dry_run' is false, it means the
12931284
test doesn't support dummy runs when -M is not specified.
1285+
1286+
A test that actually allocates the requested memory (that is, one run with
1287+
-M) runs in a subprocess, so that the memory it uses and the address space
1288+
it fragments are released when it ends. A dummy run stays in the process.
12941289
"""
12951290
def decorator(f):
1291+
from test.support import isolation
1292+
12961293
@functools.wraps(f)
12971294
def wrapper(self):
12981295
size = wrapper.size
@@ -1308,20 +1305,25 @@ def wrapper(self):
13081305
"not enough memory: %.1fG minimum needed"
13091306
% (size * memuse / (1024 ** 3)))
13101307

1311-
if real_max_memuse and verbose:
1308+
if (real_max_memuse and verbose
1309+
and not isolation.runningInSubprocess):
13121310
print()
13131311
peak = (size * memuse) / (1024 ** 3)
1314-
print(f" ... expected peak memory use: {peak:.1f} GiB")
1315-
watchdog = _MemoryWatchdog()
1316-
watchdog.start()
1317-
else:
1318-
watchdog = None
1312+
# Flushed, so that it precedes the memory usage below.
1313+
print(f" ... expected peak memory use: {peak:.1f} GiB",
1314+
flush=True)
1315+
1316+
if (real_max_memuse and has_subprocess_support
1317+
and not isolation.runningInSubprocess):
1318+
# Watch it from here: the output of the subprocess is captured.
1319+
cls = type(self)
1320+
qualname = f'{cls.__qualname__}.{f.__name__}'
1321+
proc = isolation._start_test(cls.__module__, qualname)
1322+
watchdog = _memory_watchdog(proc.pid) if verbose else None
1323+
isolation._replay_test(self, *proc.wait(tick=watchdog))
1324+
return
13191325

1320-
try:
1321-
return f(self, maxsize)
1322-
finally:
1323-
if watchdog:
1324-
watchdog.stop()
1326+
return f(self, maxsize)
13251327

13261328
wrapper.size = size
13271329
wrapper.memuse = memuse

Lib/test/support/isolation.py

Lines changed: 91 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,75 @@ def _child_environ(env):
108108
return environ
109109

110110

111-
def _run_in_subprocess(module, qualname, options, env, timeout):
112-
"""Run module.qualname (a test method or class) in a fresh subprocess.
111+
class _SubprocessTest:
112+
"""A test running in a subprocess, started by _start_test().
113113
114-
Return ``(payload, output, returncode)``, where *payload* is the decoded
115-
``{'outcomes': ..., 'durations': ...}`` mapping from the subprocess, or
116-
``None`` if it did not run to completion (crash, import error, ...).
114+
The parent can watch the subprocess (its pid) while the test runs, and
115+
must wait() for it.
116+
"""
117+
118+
def __init__(self, proc, result_path):
119+
self._proc = proc
120+
self._result_path = result_path
121+
122+
@property
123+
def pid(self):
124+
return self._proc.pid
125+
126+
def wait(self, timeout=None, tick=None, interval=1.0):
127+
"""Wait for the test to finish, calling *tick* every *interval* seconds.
128+
129+
Return ``(payload, output, returncode)``, where *payload* is the
130+
decoded ``{'outcomes': ..., 'durations': ...}`` mapping from the
131+
subprocess, or ``None`` if it did not run to completion (crash,
132+
import error, ...).
133+
"""
134+
import marshal
135+
import subprocess
136+
import time
137+
deadline = None if timeout is None else time.monotonic() + timeout
138+
try:
139+
while True:
140+
step = None if deadline is None else max(
141+
0.0, deadline - time.monotonic())
142+
# Wake up for the next tick, unless the timeout comes first.
143+
ticking = tick is not None and (step is None or step > interval)
144+
try:
145+
# communicate(), not wait(): a test writing more than a
146+
# pipe buffer would block. Retrying keeps what it read.
147+
stdout, stderr = self._proc.communicate(
148+
timeout=interval if ticking else step)
149+
break
150+
except subprocess.TimeoutExpired:
151+
if ticking:
152+
tick()
153+
continue
154+
# Report the hang rather than leaving the runner stuck.
155+
self._proc.kill()
156+
stdout, stderr = self._proc.communicate()
157+
raise _SubprocessTestError(
158+
f'test did not complete in a subprocess '
159+
f'within {timeout} seconds'
160+
) from _remote(_decode(stdout) + _decode(stderr))
161+
try:
162+
with open(self._result_path, 'rb') as f:
163+
payload = marshal.load(f)
164+
except (OSError, EOFError, ValueError):
165+
payload = None
166+
output = _decode(stdout) + _decode(stderr)
167+
return payload, output, self._proc.returncode
168+
finally:
169+
try:
170+
os.unlink(self._result_path)
171+
except OSError:
172+
pass
173+
174+
175+
def _start_test(module, qualname, options=(), env=None):
176+
"""Start module.qualname (a test method or class) in a fresh subprocess.
177+
178+
Return a _SubprocessTest. Its wait() is what removes the temporary file
179+
the subprocess writes its result to.
117180
"""
118181
import marshal
119182
import subprocess
@@ -129,26 +192,16 @@ def _run_in_subprocess(module, qualname, options, env, timeout):
129192
cmd = [sys.executable, *options, '-m', 'test.support.subprocess_runner',
130193
module, qualname, result_path,
131194
marshal.dumps(_child_config()).hex()]
132-
try:
133-
proc = subprocess.run(cmd, capture_output=True,
134-
env=_child_environ(env), timeout=timeout)
135-
except subprocess.TimeoutExpired as exc:
136-
# Report the hang rather than leaving the test runner stuck.
137-
output = _decode(exc.stdout) + _decode(exc.stderr)
138-
raise _SubprocessTestError(
139-
f'test did not complete in a subprocess '
140-
f'within {timeout} seconds') from _remote(output)
141-
try:
142-
with open(result_path, 'rb') as f:
143-
payload = marshal.load(f)
144-
except (OSError, EOFError, ValueError):
145-
payload = None
146-
finally:
195+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
196+
stderr=subprocess.PIPE, env=_child_environ(env))
197+
except BaseException:
147198
try:
148199
os.unlink(result_path)
149200
except OSError:
150201
pass
151-
return payload, _decode(proc.stdout) + _decode(proc.stderr), proc.returncode
202+
raise
203+
return _SubprocessTest(proc, result_path)
204+
152205

153206

154207
def _replay_outcome(test, outcome):
@@ -200,6 +253,19 @@ def _check_returncode(returncode, output, what):
200253
raise exc from _remote(output)
201254

202255

256+
def _replay_test(test, payload, output, returncode):
257+
"""Reproduce in *test* the result that _SubprocessTest.wait() returned."""
258+
if payload is None:
259+
exc = _SubprocessTestError(
260+
f'test did not complete in a subprocess (exit code {returncode})')
261+
raise exc from _remote(output)
262+
# The parent measures the test method's own duration (the real cost of the
263+
# isolated run, subprocess startup included), so nothing to forward here.
264+
# Replay the outcomes first: a failure of the test itself is more useful.
265+
_replay_outcomes(test, payload['outcomes'])
266+
_check_returncode(returncode, output, 'test')
267+
268+
203269
def _isolate_method(func, options, env, timeout):
204270
@functools.wraps(func)
205271
def wrapper(self, /, *args, **kwargs):
@@ -209,18 +275,8 @@ def wrapper(self, /, *args, **kwargs):
209275
_check_subprocess_support()
210276
cls = type(self)
211277
qualname = f'{cls.__qualname__}.{func.__name__}'
212-
payload, output, returncode = _run_in_subprocess(cls.__module__,
213-
qualname, options,
214-
env, timeout)
215-
if payload is None:
216-
exc = _SubprocessTestError(
217-
f'test did not complete in a subprocess (exit code {returncode})')
218-
raise exc from _remote(output)
219-
# The parent measures this method's own duration (the real cost of the
220-
# isolated run, subprocess startup included), so nothing to forward here.
221-
# Replay the outcomes first: a failure of the test itself is more useful.
222-
_replay_outcomes(self, payload['outcomes'])
223-
_check_returncode(returncode, output, 'test')
278+
proc = _start_test(cls.__module__, qualname, options, env)
279+
_replay_test(self, *proc.wait(timeout))
224280
return wrapper
225281

226282

@@ -244,9 +300,8 @@ def setUpClass(cls):
244300
_check_subprocess_support()
245301
# Run the whole class in a single subprocess and stash the outcomes
246302
# for the test methods to replay.
247-
payload, output, returncode = _run_in_subprocess(cls.__module__,
248-
cls.__qualname__,
249-
options, env, timeout)
303+
proc = _start_test(cls.__module__, cls.__qualname__, options, env)
304+
payload, output, returncode = proc.wait(timeout)
250305
if payload is None:
251306
exc = _SubprocessTestError(
252307
f'class did not complete in a subprocess (exit code {returncode})')

Lib/test/test_support.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,17 +1231,28 @@ def test_timeout_reported_as_error(self):
12311231
self.assertEqual(len(result.errors), 1)
12321232
self.assertIn(f'within {TIMEOUT} seconds', result.errors[0][1])
12331233

1234+
@support.requires_subprocess()
1235+
def test_bigmemtest_isolates_a_real_run(self):
1236+
# A dummy run (no -M) stays in this process, a real run does not.
1237+
for memlimit in (0, support._1G):
1238+
with self.subTest(real_max_memuse=memlimit):
1239+
with support.swap_attr(support, 'real_max_memuse', memlimit):
1240+
result = self._run('BigmemSample')
1241+
self.assertEqual(result.testsRun, 1)
1242+
self.assertEqual(self._names(result.failures), [])
1243+
self.assertEqual(self._names(result.errors), [])
1244+
12341245
def test_skipped_without_subprocess_support(self):
12351246
# On a platform without subprocess support the test is skipped in the
12361247
# parent, before any subprocess is spawned.
12371248
calls = []
1238-
orig = isolation._run_in_subprocess
1249+
orig = isolation._start_test
12391250
with support.swap_attr(support, 'has_subprocess_support', False):
1240-
isolation._run_in_subprocess = lambda *a, **k: calls.append(a)
1251+
isolation._start_test = lambda *a, **k: calls.append(a)
12411252
try:
12421253
result = self._run('MethodSample.test_pass')
12431254
finally:
1244-
isolation._run_in_subprocess = orig
1255+
isolation._start_test = orig
12451256
self.assertEqual(result.testsRun, 1)
12461257
self.assertEqual(len(result.skipped), 1)
12471258
self.assertEqual(calls, [])

0 commit comments

Comments
 (0)