@@ -108,12 +108,75 @@ def _child_environ(env): |
108 | 108 | return environ |
109 | 109 |
|
110 | 110 |
|
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(). |
113 | 113 |
|
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. |
117 | 180 | """ |
118 | 181 | import marshal |
119 | 182 | import subprocess |
@@ -129,26 +192,16 @@ def _run_in_subprocess(module, qualname, options, env, timeout): |
129 | 192 | cmd = [sys.executable, *options, '-m', 'test.support.subprocess_runner', |
130 | 193 | module, qualname, result_path, |
131 | 194 | 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: |
147 | 198 | try: |
148 | 199 | os.unlink(result_path) |
149 | 200 | except OSError: |
150 | 201 | pass |
151 | | - return payload, _decode(proc.stdout) + _decode(proc.stderr), proc.returncode |
| 202 | + raise |
| 203 | + return _SubprocessTest(proc, result_path) |
| 204 | + |
152 | 205 |
|
153 | 206 |
|
154 | 207 | def _replay_outcome(test, outcome): |
@@ -200,6 +253,19 @@ def _check_returncode(returncode, output, what): |
200 | 253 | raise exc from _remote(output) |
201 | 254 |
|
202 | 255 |
|
| 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 | + |
203 | 269 | def _isolate_method(func, options, env, timeout): |
204 | 270 | @functools.wraps(func) |
205 | 271 | def wrapper(self, /, *args, **kwargs): |
@@ -209,18 +275,8 @@ def wrapper(self, /, *args, **kwargs): |
209 | 275 | _check_subprocess_support() |
210 | 276 | cls = type(self) |
211 | 277 | 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)) |
224 | 280 | return wrapper |
225 | 281 |
|
226 | 282 |
|
@@ -244,9 +300,8 @@ def setUpClass(cls): |
244 | 300 | _check_subprocess_support() |
245 | 301 | # Run the whole class in a single subprocess and stash the outcomes |
246 | 302 | # 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) |
250 | 305 | if payload is None: |
251 | 306 | exc = _SubprocessTestError( |
252 | 307 | f'class did not complete in a subprocess (exit code {returncode})') |
|